Svelte, framework digne d'intérêt

Cet article a été mis à jour le 15/02/2023 pour refléter les derniers changement concernant l'outillage de svelte qui a évolué de "degit" vers "vite" (merci à Aurélien Bouteiller pour cette mise à jour).

Svelte, le "New kid on the block" des frameworks front end dont vous avez sans doute entendu parler est en pleine renaissance depuis sa version 3 majeure (avril 2019).

L'observant d'un coin de l’œil depuis ses débuts, je me suis dit que c'était le bon moment de regarder d'un peu plus près ce phénomène, et d'évaluer l'opportunité de l'utiliser dans des projets.

Attention, si vous êtes développeur front et que vous ne connaissez pas encore Svelte, vous risquez d'adorer ce qui suit…

Pourquoi Svelte est spécial ?

Svelte est un framework orienté composant, comme React ou Vue, mais avec des différences importantes.

Dans React par exemple, on est encouragé à écrire du code plutôt "state-driven" de manière déclarative, code qui sera péniblement converti en opérations DOM avec des techniques de comparaison de virtual DOM qui finissent par être coûteuses et complexes à appréhender.

Svelte fonctionne autrement, au lieu de manipuler un DOM virtuel au runtime, tout se fait pendant le build !

bouche-bee.jpeg

Svelte transforme au buildtime vos composants en code performant qui va directement modifier les infimes parties concernées par l'update dans l'arbre DOM (le vrai).

La première conséquence de ce choix, c'est la possibilité de créer une architecture poussée de composants tout en conservant des performances excellentes.

Pour tendre vers un paradigme réactif, chaque framework y va de sa solution, plus ou moins compliquée, pour gérer les états, c'est ainsi par exemple que les hooks sont apparus dans React (qui n'est pas réactif).

Avec Svelte, le choix a été fait de ne pas utiliser d'API spécifique mais de capitaliser sur le langage (JS donc) pour améliorer l'expérience développeur.

jim-carrey-yes-sir.gif

Un choix audacieux et en même temps astucieux, qui fait que la plupart des besoins concernant la gestion du state d'un composant trouvent des solutions simples et naturelles, et pour le reste Svelte propose une armada d'astuces et autres sucres syntaxiques.

Et en vrai, ça dit quoi ?

Pour se chauffer avec Svelte, rien de tel qu'un petit "hands-on", et pour se rapprocher de la vraie vie, c'est mieux de se frotter également à une API.

Objectif : faire un front web qui permet de naviguer dans les collections de données de l'excellente API Star Wars.

Etape 1 : Initialisation du projet

  • Créer le projet :

    Pour démarrer le projet avec une bonne base nous allons utiliser vite avec le template de Svelte.

      npm create vite starwars-front -- --template svelte
    
  • Changer le dossier courant :

      cd starwars-front
    
  • Installer les dépendances :

      npm i
    

    Dans notre projet nous aurons besoin de 2 dépendances supplémentaires, autant les installer maintenant :

    • dayjs : pour manipuler / formater les dates

    • svelte-navigator : pour bénéficier d'un routeur simple et efficace dans Svelte

    npm i -D dayjs svelte-navigator
  • Démarrer le serveur en mode développement :

      npm run dev
    

    En consultant l'adresse http://localhost:5137 vous devriez voir une application compteur déjà prête à l'essai.

    Le serveur recharge à chaud la page en cas de changement (hot reload), on peut donc définitivement laisser cette commande tourner pendant qu'on peaufine notre application.

  • Rendu de la page

    On modifie index.html pour y ajouter materialize css et la police Star Wars :

      <title>Star Wars</title>
    
      <link rel='icon' type='image/png' href='favicon.png'>
    
      <link rel='stylesheet' href='//fonts.googleapis.com/icon?family=Material+Icons'>
      <link rel='stylesheet' href='//cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/css/materialize.min.css'>
      <link rel='stylesheet' href='//fonts.cdnfonts.com/css/star-wars'>
    
      <link rel='stylesheet' href='/global.css'>
    
      <script src='//cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/js/materialize.min.js'></script>
    
  • Ajouter des styles globaux

    Dans public/global.css, on ajoute la police Star Wars du titre et quelques styles custom de notre thème, pour le reste la CSS reset material fera bien l'affaire :

      html, body {
          background-color: black;
      }
    
      nav ul a {
          color: black;
          font-weight: bold;
      }
    
      h1 {
          font-family: 'Star Wars', sans-serif;
      }
    
  • Composant principal :

    Pour l'instant notre composant principal App.svelte n'affiche qu'un titre :

      <div class='amber-text'>
          <h1 class='center-align'>Star Wars</h1>
          <main class='section amber lighten-4 black-text'>
          </main>
      </div>
    
      <style>
      main {
          padding: 1em;
          margin: 0 auto;
      }
    
      h1 {
          text-transform: uppercase;
          font-size: 4em;
          font-weight: 600;
      }
      </style>
    

    Quant au script main.js, il est réduit à sa plus simple expression :

      import App from './App.svelte'
    
      const app = new App({
        target: document.body,
      })
    
      export default app
    

    Résultat attendu :

    screenshot-step1.png

    Code source : NijiDigital/svelte-starwars step1

    Résumé : dans cette étape, nous avons vu comment créer une app, poser un cadre de départ pour préparer le travail, et adapter le contenu en ne manipulant que des basiques du web (HTML, CSS).

Etape 2 : L'intro Star Wars

Pour être dans le thème, une petite intro animée de Star Wars s'impose, et pour ça on va trouver tout ce qu'il faut dans le projet GitHub PolarNotion/starwarsintro.

  • Feuille de style de l'animation :

    Ajouter starwarsintro.css dans le dossier public/ et modifier index.html comme suit :

      <link rel='stylesheet' href='/starwarsintro.css'>
    
  • Images :

    On a besoin d'une image de fond et un logo :

Placer ces 2 assets statiques dans public/img/.

  • Composant de l'intro :

    Pour afficher l'intro animée, on crée un nouveau composant src/Intro.svelte avec :

      <div class='star-wars-intro black container'>
          <p class='intro-text amber-text'>
              A few days ago, during...
          </p>
          <h2 class='main-logo'>
              <img alt='Niji logo' src='img/niji-logo.png'>
          </h2>
          <div class='main-content'>
              <div class='title-content'>
                  <p class='content-header'>EPISODES IV-VI<br/>A Movie Marathon
                  </p>
                  <br>
                  <p class='content-body'>
                      After years of galactic silence, civilization is on the brink of a new Star Wars release. Now, with the
                      Force preparing to awaken, the people of Earth seek solace in films of old. With nowhere to turn, they
                      gather in great numbers and watch the original trilogy without rest. Three films. 6 hours. 24 minutes.
                      Popcorn. Slushies. Total elation.
                  </p>
              </div>
          </div>
      </div>
    

    Puis on ajoute le composant dans src/App.svelte :

      <main class='section amber lighten-4 black-text'>
          <Intro/>
      </main>
    

    Résultat attendu :

    screenshot-step2.png

    Code source : NijiDigital/svelte-starwars step2

    Résumé : nous avons ajouté une feuille de style, récupéré du contenu HTML que nous avons intégré dans un composant Svelte.

Etape 3 : Les planètes de Star Wars

Dans cette étape, nous allons interroger l'API Star Wars pour obtenir la liste des planètes et l'afficher.

Pour ajouter ce nouvel écran, il nous faut commencer à s'intéresser aux routes front que l'on souhaite exposer :

  • La route racine / (homepage) affichera l'intro

  • La route /planets affichera la liste des planètes

Pour pouvoir naviguer entre ces routes on peut proposer un menu, et propulser le tout avec un routeur.

C'est là qu'intervient svelte-navigator qui fournit tout de dont on a besoin pour établir nos routes sans souffrir.

Commençons par ajouter la CSS fournie par svelte-navigator, dans main.js :

import App from './App.svelte'
import 'svelte-navigator/svelte-navigator.css'
  • La barre de menu :

    Créer un nouveau composant MenuBar.svelte avec :

      <script>
        import { Link } from 'svelte-navigator'
      </script>
    
      <header>
          <nav class='amber black-text'>
              <ul>
                  <li>
                      <Link to='/'>Accueil</Link>
                  </li>
                  <li>
                      <Link to='planets'>Planètes</Link>
                  </li>
              </ul>
          </nav>
      </header>
    

    Le tag Link facilite le pointage vers la bonne route, telle qu'elle est déclarée dans le routeur, et permet de ne pas avoir à gérer le fait d'empêcher le navigateur d'interroger le serveur lors du chargement de la nouvelle URI, ce qui serait le cas avec un simple hyperlien (tag a).

    Ajouter le composant MenuBar à notre app, ainsi que le composant Planets (à venir) :

    Dans App.svelte :

      <script>
        import { Route, Router } from 'svelte-navigator'
        import MenuBar from './MenuBar.svelte'
        import Intro from './Intro.svelte'
        import Planets from './Planets.svelte'
      </script>
    
      <div class='amber-text'>
          <Router primary='{false}'>
              <h1 class='center-align'>Star Wars</h1>
              <MenuBar/>
              <main class='section amber lighten-4 black-text'>
                  <Route path='/'>
                      <Intro/>
                  </Route>
                  <Route path='planets'>
                      <Planets/>
                  </Route>
              </main>
          </Router>
      </div>
    

    Notez la coincidence entre path du routeur et to du menu.

    • La liste des planètes :

On crée maintenant un composant Planets.svelte qui va prendre en charge le chargement et l'affichage des planètes :

    <script>
      import { starWarsApiBaseUrl } from './constants'
      import { lastPath } from './helper'
      import { planets } from './store'
      import { get } from 'svelte/store'
      import { Link } from 'svelte-navigator'

      const getPlanets = async () => {
        let value = get(planets)
        if (value) {
          return value
        }
        const response = await fetch(`${starWarsApiBaseUrl}/planets`)
        const data = await response.json()
        value = data.results.map(item => {
          const { url, ...props } = item
          const id = lastPath(url)
          return { ...props, id }
        })
        planets.set(value)
        return value
      }
    </script>

    <div class='container amber lighten-5'>
        <div class='row'>
            <div class='col s12'>
                {#await getPlanets()}
                    <strong>Chargement en cours…</strong>
                {:then planets}
                    <h5>Liste des planètes :</h5>
                    <div class='collection'>
                        {#each planets as planet}
                            <Link class='collection-item amber lighten-4 black-text' to='{`/planets/${planet.id}`}'>
                                {planet.name}
                            </Link>
                        {/each}
                    </div>
                {:catch error}
                    <h6>Oops… quelque chose s'est mal passé :-(</h6>
                {/await}
            </div>
        </div>
    </div>

Notez l'utilisation de fetch et la manière très naturelle de coder en JS, ainsi que l'utilisation très pratique du bloc {#await dans le template qui permet d'attendre la résolution de la promesse pour ensuite afficher la liste dans une boucle {#each.

Pour fonctionner, ce composant a besoin de 3 petits helpers supplémentaires :

  • constants.js : pour centraliser la base URL de l'API Star Wars (DRY).

      export const starWarsApiBaseUrl = 'https://swapi.dev/api/'
    
  • helper.js : qui fournit une petite fonction capable d'extraire l'ID d'une planète (ou toute autre ressource Star Wars) depuis une route API. L'API respecte le style d' architecture REST et ses principes de localisation de ressources, l'identifiant de ressource est donc le dernier morceau du chemin.

      export const lastPath = (url) => {
        const paths = url.split('/').filter(item => item)
        return paths[paths.length - 1]
      }
    
  • store.js : qui fournit un utilitaire permettant de "persister" des données dans un store pouvant être vu comme un cache qui évite d'interroger l'API à chaque fois qu'on revient sur la route.

      import { writable } from 'svelte/store'
    
      export const planets = writable()
    
      const details = {
        planets: {},
      }
    
      export const getPlanetDetailsStore = (id) => details.planets[id]
    
      export const createPlanetDetailsStore = (id) => {
        const detailStore = writable()
        details.planets[id] = detailStore
        return detailStore
      }
    

Cet utilitaire s'appuie sur le mécanisme de store fourni par Svelte qui permet de partager des données entre composants, et s'abonner à d'éventuels changements.

Résultat attendu :

screenshot-step3.png

Code source : NijiDigital/svelte-starwars step3

Résumé : nous avons ajouté un menu et un routeur pour naviguer, un composant Svelte qui charge des données provenant de l'API, qui utilise la syntaxe de template Svelte pour gérer l'affichage de la liste, et nous avons saupoudré de vanilla JS pour factoriser du code.

Etape 4 : Détail d'une planète

C'est bien d'afficher la liste des planètes, mais c'est mieux de pouvoir aussi voir les détails.

Pour ce nouvel écran, il nous faut :

  • Déclarer une nouvelle route /planets/:id, dans App.svelte :

      <script>import Planets from './Planets.svelte'
        import Planet from './Planet.svelte'
      </script>
    
      <div class="amber-text">
          <Router primary={false}><main class="section amber lighten-4 black-text"><Route path="planets/:id" let:params>
                      <Planet id="{params.id}"/>
                  </Route>
              </main>
          </Router>
      </div>

    Notez l'utilisation de let:params pour récupérer l'ID de la route, et son injection dans le composant Planet via un simple attribut HTML.

  • Créer un nouveau composant Planet.svelte responsable du chargement des détails et de leur affichage dans une carte :

      <script>
        import { starWarsApiBaseUrl } from './constants'
        import { lastPath } from './helper'
        import {
          createPlanetDetailsStore,
          getPlanetDetailsStore,
        } from './store'
        import { get } from 'svelte/store'
        import { Link } from 'svelte-navigator'
        import dayjs from 'dayjs'
    
        export let id
    
        const getPlanet = async () => {
          let planetDetailStore = getPlanetDetailsStore(id)
          let value = planetDetailStore && get(planetDetailStore)
          if (value) {
            return value
          }
          const response = await fetch(`${starWarsApiBaseUrl}/planets/${id}`)
          const data = await response.json()
          const { films, ...props } = data
          const filmIds = films.map(url => lastPath(url))
          value = { ...props, filmIds }
          planetDetailStore = createPlanetDetailsStore(id)
          planetDetailStore.set(value)
          return value
        }
      </script>
    
      <div class="container amber lighten-5">
          {#await getPlanet()}
              <strong>Chargement en cours…</strong>
          {:then planet}
              <div class="row">
                  <div class="col s12">
                      <h5>Détails de la planète :</h5>
                  </div>
              </div>
              <div class="row">
                  <div class="col s12">
                      <div class="row">
                          <div class="input-field col s12">
                              <input readonly type="text" id="name" value="{planet.name}">
                              <label class="active" for="name">Nom :</label>
                          </div>
                      </div>
                      <div class="row">
                          <div class="input-field col s12">
                              <input readonly type="text" id="created"
                                     value="{dayjs(planet.created).format('D/MM/YYYY')}">
                              <label class="active" for="created">Date de création :</label>
                          </div>
                      </div>
                      <div class="row">
                          <div class="input-field col s12">
                              <input readonly type="text" id="population" value="{planet.population}">
                              <label class="active" for="population">Population :</label>
                          </div>
                      </div>
                      <div class="row">
                          <div class="input-field col s12">
                              <input readonly type="text" id="climate" value="{planet.climate}">
                              <label class="active" for="climate">Climat :</label>
                          </div>
                      </div>
                      <div class="row">
                          <div class="input-field col s12">
                              <input readonly type="text" id="diameter" value="{planet.diameter}">
                              <label class="active" for="diameter">Diamètre :</label>
                          </div>
                      </div>
                      <div class="row">
                          <div class="input-field col s12">
                              <div id="films" class="collection">
                                  {#each planet.filmIds as filmId}
                                      <Link class="collection-item amber lighten-4 black-text"
                                            to={`/films/${filmId}`}>{filmId}</Link>
                                  {/each}
                              </div>
                              <label class="active" for="films">Films associés :</label>
                          </div>
                      </div>
                  </div>
              </div>
          {:catch error}
              <h6>Oops… quelque chose s'est mal passé :-(</h6>
          {/await}
      </div>
    

    Notez le simple ajout de export à let id pour permettre une injection de paramètre par le composant parent (la gestion du state n'est même pas un sujet ici).

    L'essentiel du code est lié à la présentation HTML un peu exigeante avec material, côté JS c'est très similaire à ce que nous avions fait pour appréhender la liste des planètes, avec l'utilisation du store également.

    Résultat attendu :

    screenshot-step4.png

    Code source : NijiDigital/svelte-starwars step4

    Résumé : nous avons ajouté une route et un nouveau composant Svelte, en réutilisant les mêmes patterns et en profitant des facilités offertes par les templates Svelte, cette étape est de ce point de vue intéressante car on commence à capitaliser et à aller plus vite.

Suite…

Pour poursuivre ce hands-on, on pourrait ajouter des écrans pour avoir la liste des films ainsi que leur détail, et lier entre eux les films et les planètes, puis faire de même avec les personnages, etc ; et obtenir ainsi une appli web complète pour consulter l'ensemble des données Star Wars, mais cela dépasse le cadre de ce billet.

Vous trouverez dans github.com/NijiDigital/svelte-starwars le résultat final y intégrant les films, ainsi que la version en ligne de la démo.

Sentez-vous libre de contribuer et compléter avec les autres données disponibles via l'API Star Wars si le cœur vous en dit.

Pour ceux qui s'intéressent plus précisément à l'outillage de Svelte avec Sveltekit, et le support de TypeScript, vous pouvez consulter la branche sveltekit du projet concoctée spécialement pour vous montrer une autre façon de faire, encore plus industrielle et moderne.

Conclusion

Je dois avouer que, durant de ce petit voyage, j'ai beaucoup apprécié Svelte.

Au fil des années j'ai été confronté à de nombreuses technologies front web, des bonnes vieilles JSP à Angular, React et Vue en passant par à JQuery, Knockout, Backbone et Ember

Cela doit immanquablement influencer mon jugement et évidemment ce qui suit contient une part non négligeable de subjectivité, néanmoins certains arguments me semblent intéressants à partager pour que vous puissiez vous faire une idée rapidement.

Qu'est-ce qui fait que j'aime bien Svelte ?

Pour comprendre, il faut nécessairement faire une petite digression pour parler des autres frameworks :

Angular

Angular est très industriel, c'est souvent ce qui plaît, on implémente que des classes (trop), c'est très verbeux ( trop), on obtient de très bons résultats lorsqu'on maîtrise toutes les notions (subtilités du 2 way binding, rxjs, …) mais on peut aussi parfois obtenir des usines à gaz, donc efficace sous certaines conditions mais je n'ai jamais pris de plaisir à coder avec.

Ce qui m'attire dans Angular c'est l'usage assez puissant que l'on fait de RxJS, c'est un peu la caution réactive du framework qui me plait (au passage on peut tout à fait l'utiliser ailleurs, y compris sur le backend !).

Vue

Un challenger que j'ai eu tendance à aimer, probablement à cause du cadre qui sépare la couche présentation de la logique applicative dans lequel je me reconnais, même si le langage de templating ressemble un peu trop à Angular à mon goût.

Vue est doté d'une excellente doc, quasiment depuis ses débuts.

Son plus gros défaut est probablement sa communauté, qui ne rivalise pas (encore) avec celle de React ; raison qui peut expliquer une certaine frilosité en France.

React

Au début (2015 pour moi) je n'étais pas fan de JSX, sur la plateforme que je réalisais à l'époque il fallait un front et un développeur de mon équipe était très motivé pour le faire en React (à la différence de moi), après avoir décidé de lui faire confiance, quelques années après l'équipe a grossi et j'ai fini par m'habituer à React et devenir même prescripteur, tellement c'est efficace et rationnel, que ce soit pour une petite appli comme pour un gros projet industriel.

Il y a eu pas mal de changements dans les façons de faire avec React au fil du temps (classes, redux, hooks), et même aujourd'hui certains sujets continuent de faire débat (useState, useEffect).

La gestion du state reste un sujet délicat, et parfois un cauchemar (Understanding common frustrations with React Hooks), avec le risque de retomber un peu dans les mêmes travers qu'Angular : avec des développeurs expérimentés tout va bien.

Svelte

Quant à Svelte, et bien il réunit à peu près tout ce que je recherche inconsciemment quand il s'agit de développement front…

Ce que j'aime :

  • La simplicité, le ticket d'entrée très bas explique en grande partie une adoption en plein essor (Is Svelte most growing web tech?).

  • Du JS d'abord, le plus vanilla possible, et du HTML augmenté qui oblige à apprendre un nouveau langage de template à l'instar de Vue, mais les choses à apprendre et à comprendre sont simples, faciles à prendre en main et bien documentées donc ce n'est pas un frein.

  • Orienté composant par construction : chaque source inclut sa dynamique, sa présentation, son style, d'une manière particulièrement simple, évidente et élégante.

  • Pas besoin de manipuler des concepts avancés pour produire (ticket d'entrée) : on apprend en marchant et on produit vite.

  • Le parti pris de revoir les fondations : partir sur un outil qui va construire une app full JS, en éliminant pas mal de problématiques qui sont portées par le build.

  • Une impression de liberté : il s'agit d'un framework, à priori donc plus structurant qu'une lib (React) concernant la façon de faire, mais Svelte tire son épingle du jeu en capitalisant un maximum sur ce qui fonctionne bien pour un développeur et en assumant la pleine cohabitation JS, HTML, CSS, finalement on arrive à produire sans passer son temps à aller chercher un tas de briques dans le framework.

Pour moi Svelte n'est pas un n-ième challenger, ici il y a une vraie différence qui pourrait bien faire la différence.

Dans une organisation en entreprise, je pense qu'il peut percer tant son cadre est pertinent pour fluidifier les interactions entre différentes populations : développeurs, designers et intégrateurs ; le travail des développeurs pour obtenir une app réactive en intégrant du contenu HTML et un style guide nécessite peu de rework.

A confirmer (ou pas) dans un projet à l'échelle…, pour ma part, l'envie est bien là.

Et vous, seriez-vous prêt à basculer ?