Les vendeurs de bande-passante la détestent ! Découvez comment cette application est devenue Svelte et a réduit sa taille par 10

Svelte ?

Si vous suivez l’actualité du développement front-end, Svelte ne vous est probablement pas étranger. Si c’est le cas, je ne saurais que vous conseiller l’article de Florent Bergé, paru sur le blog de Ippon, et qui explique les promesses de Svelte, ainsi que ses principales caractéristiques.

Les concepts portés par Svelte expliquent en partie sa popularité, qui ne semble pas vouloir faiblir.

L’un des concepts clés, qui est même le slogan du framework, est code less, do more - codez moins, faîtes plus. Autrement dit, Svelte nous promet de pouvoir développer les mêmes fonctionnalités qu’avec d’autres framework, mais avec moins de code que ces derniers.

Nous allons donc nous concentrer sur ce point, en développant une simple liste de gestion de tâches en Svelte, que nous comparerons avec la même application développée cette fois en Angular.

Avant de commencer

La raison du choix d’Angular tient simplement au fait qu’il s’agit de la technologie front que je maîtrise le plus, Vue semble plus proche de Svelte dans sa conception, mais cela pourra faire l’objet d’un autre article.

Pour chacun des frameworks, j’ai gardé les briques standards. J’ai pris le parti de travailler sans librairie externe, afin de se concentrer sur les fonctionnalités de base de ces cadres de travail, et de rester sur une comparaison native. Deux exceptions, toutefois, à cela :

  • RxJS, qui est fréquemment utilisé avec Angular pour implémenter la programmation réactive et constitue de facto une quasi-brique du framework.
  • svelte-spa-router, car Svelte n’embarque pas de routeur et cela permet d’implémenter plusieurs “pages” à l’application. Je ne m’attarderais toutefois pas sur cette partie du code.

Dans la même veine, la version Angular est développée en TypeScript et la version Svelte en JavaScript. Si Svelte supporte officiellement TypeScript depuis juillet 2020, ce dernier utilise, par défaut, le JavaScript.

Même s’il est possible d’externaliser le template et les styles des composants en Angular, j’ai pris le parti de les intégrer dans le composant, afin d’avoir une structure similaire à celle d’un composant Svelte.

Par souci de simplification, je nommerai les composants par leur nom simple (par exemple TaskCard), sachant que par convention, le suffixe Component a été ajouté sur les composants Angular.

Dans les deux cas, afin de faciliter l’utilisation des applications, les données sont écrites en dur dans le code. Ce dernier est disponible sur le dépôt Github suivant : https://github.com/Bergamof/svelte-demo

Vous verrez peut-être une différence entre le nombre de lignes du code affiché et celui que j’annonce. Cela est dû au fait que pour le calcul, je ne prends pas en compte les sauts de ligne que j’utilise afin d’aérer le code.

Composition de l’application

L’application est composée de deux écrans.

Le premier, l’écran par défaut, contient la liste des tâches. Chaque tâche est composée :

  • D’un titre, obligatoire
  • D’un identifiant, caché
  • De l’identifiant du responsable de la tâche, non obligatoire. L’identifiant renvoie vers un utilisateur, composé d’un identifiant et d’un nom.
  • D’un état (réalisé/en cours)

Voici la composition des écrans, profitez-en pour admirer mon formidable talent d’UI designer.

Le second écran est le formulaire de création de tâche.

Je ne reviendrai pas sur le composant App qui, dans les deux cas, ne contient que la barre de navigation et l’appel au routeur.

Les stores/services

Angular n’apporte pas de store avec la version de base. Il existe des librairies qui permettent d’en implémenter (par exemple NgRx), mais le mécanisme d’injection de dépendance permet de remplacer efficacement, dans le cas d’une application de taille moyenne, un store, via la création de services.

Angular :

TaskService

import { Injectable } from '@angular/core';
import { Task } from '../entities/task';
import { BehaviorSubject, defer, Observable, Subject } from 'rxjs';

@Injectable({ providedIn: 'root' })
export class TaskService {
  private nextId = 1;
  private taskList: Task[] = [
     { id: this.nextId++, completed: true, assigneeId: 1, title: 'Example task' },
     { id: this.nextId++, completed: false, assigneeId: 1, title: 'Another task' }
  ];
  private taskListObservable$: Subject<Task[]> = new BehaviorSubject<Task[]>(this.taskList);

  public getAll(): Observable<Task[]> {
     return defer(() => {
        console.log('New subscription to the task service');
        return this.taskListObservable$.asObservable();
     });
  }

  public addTask(task: Task): void {
     task.id = this.nextId++;
     this.taskList = [...this.taskList, task];
     this.taskListObservable$.next(this.taskList);
  }

  public changeTaskCompletion(taskToUpdate: Task, completion: boolean): void {
     this.taskListObservable$.next(this.taskList.map(task => {
        if (task.id === taskToUpdate.id) {
           task.completed = completion;
        }
        return task;
     }));
  }
}

UserService

import { Injectable } from '@angular/core';
import { User } from '../entities/user';
import { BehaviorSubject, Observable, Subject } from 'rxjs';

@Injectable({ providedIn: 'root' })
export class UserService {
  private userList: User[] = [{ id: 1, name: 'Adrien' }];
  private userListObservable$: Subject<User[]> = new BehaviorSubject(this.userList);

  public getAll(): Observable<User[]> {
     return this.userListObservable$;
  }
}

Svelte :

TaskStore

import { writable } from 'svelte/store';

import { Task } from '../entities/task';

function createTaskList() {
let nextId = 1;

const { subscribe, update } = writable([
 new Task(nextId++, true, 1, 'Example task'),
 new Task(nextId++, false, 1, 'Another task')
]);

return {
 subscribe: callback => {
  console.log('New subscription to the task store');
  return subscribe(callback);
 },
 addTask: (taskToAdd) =>
  update((taskList) => {
   taskToAdd.id = nextId++;
   return [...taskList, taskToAdd];
  }),

 changeTaskCompletion: (taskToUpdate, completion) =>
  update((taskList) =>
   taskList.map((task) => {
    if (task.id === taskToUpdate.id) {
     task.completed = completion;
    }
    return task;
   })
  )
};
}

export const taskStore = createTaskList();

UserStore

import { writable } from 'svelte/store';

import { User } from '../entities/user';

function createUserList() {
const { subscribe } = writable([new User(1, 'Adrien')]);

return { subscribe };
}

export const userStore = createUserList();

Niveau comptable on est, sur les tâches, à 32 lignes côté Angular et 31 pour Svelte. Et pour la partie utilisateurs, 13 et 8 lignes.

Ici, peu de différences dans le volume de code, petit désavantage pour Angular, d’autant plus qu’il faut gérer soi-même les listes de données.

TaskList

Angular :

import { Component } from '@angular/core';
import { TaskService } from '../shared/task-service';
import { Observable } from 'rxjs';
import { Task } from '../entities/task';

@Component({
  selector: 'app-task-list',
  template: `
     <h1>Angular TaskList</h1>

     <a routerLink="/tasks/new">Create a task</a>
     <ng-container *ngIf="allTasksObservable|async as taskList">
        <ng-container *ngIf="taskList.length>0; else notask">
           <ul>
              <app-task-card *ngFor="let task of taskList" [task]="task"></app-task-card>
           </ul>
           <div>{{ (allTasksObservable|async).length }} task{{ (allTasksObservable|async).length > 1 ? 's' : '' }}</div>
        </ng-container>
     </ng-container>

     <ng-template #notask>
        <div>No task</div>
     </ng-template>
  `
})
export class TaskListComponent {
  public allTasksObservable: Observable<Task[]>;

  constructor(taskService: TaskService) {
     this.allTasksObservable = taskService.getAll();
  }
}

Svelte :

<script>
import TaskCard from './TaskCard.svelte';
import { taskStore } from '../stores/task-store';
import { link } from 'svelte-spa-router';
</script>

<h1>Svelte TaskList</h1>

<a href="/tasks/new" use:link>Create a task</a>
{#if $taskStore.length > 0}
<ul>
 {#each $taskStore as task}
  <TaskCard {task} />
 {/each}
</ul>
<div>{$taskStore.length} task{$taskStore.length > 1 ? 's' : ''}</div>
{:else}
<div>No task</div>
{/if}

32 lignes pour Angular et 18 pour Svelte !

Dans ce premier composant on passera vite sur la partie scripting qui est réduite au minimum.

On peut cependant déjà voir l’un des grands avantages de Svelte : la réduction du code boilerplate. Ici l’import du store suffit à le rendre disponible.

La partie template est ici nettement plus intéressante. En effet, dans le cas où on n’aurait pas de tâche à afficher, on veut afficher un message indiquant cette absence de tâche.

Pour implémenter un else avec la directive *ngIf, il est nécessaire d’utiliser un ng-template qui sera utilisé en lieu et place du code HTML englobé par le *ngIf, dans le cas où ce dernier ne serait pas satisfait.

Côté Svelte la structure en balise du if permet d’implémenter de manière simple un else.

En ce qui concerne le mécanisme de boucles, on note la même différence entre directive (pour Angular avec *ngFor) et bloc d’instruction (pour Svelte avec {#each}...{/each}). Chaque solution apporte son lot d’avantages et d’inconvénients.

Le plus intéressant dans ce composant reste peut-être le dernier point : les appels asynchrones.

Dans les deux cas, on fait plusieurs appels : un pour afficher la liste si elle contient des éléments, un pour afficher le nombre de tâches en cours, et un pour afficher le pluriel, le cas échéant. Si, je le concède, ce n’est pas forcément très propre. Cela permet de mettre en application l’appel asynchrone directement dans les templates.

Les deux versions sont assez simples à mettre en place. Je trouve personnellement la version de Angular plus claire, car plus descriptive.

Toutefois, ce qui nous intéresse ici c’est ce qu’il va se passer dans la console.

Pour Angular :

Et pour Svelte :

Grâce à la journalisation mise en place plus haut, on se rend compte que Angular a ouvert trois souscriptions (une pour chaque pipe async), quand Svelte n’en a ouvert qu’une. La gestion des souscriptions est donc bien optimisée par Svelte.

Évidemment, dans un cas de production, on aurait implémenté un système pour gérer l’observable, mais encore une fois, cela aurait nécessité bien plus de code.

TaskCard

Angular :

import { Component, Input, OnInit } from '@angular/core';
import { Task } from '../entities/task';
import { TaskService } from '../shared/task-service';
import { UserService } from '../shared/user-service';

@Component({
  selector: 'app-task-card',
  template: `
     <li class="task">
        <label [attr.for]="'completion-task-' + task.id">
           <input
              [id]="'completion-task-' + task.id"
              type="checkbox"
              [checked]=task.completed
              (change)="changeTaskCompletion()" />
           {{task.completed ? 'Completed' : 'In progress'}}
        </label>

        <h1>{{task.id}} - {{task.title}}</h1>
        <div>{{taskUserName}}</div>
     </li>
  `,
  styles: [`
     .task {
        background: aquamarine;
        border: 2px solid #cccccc;
        border-radius: 10px;

        list-style-type: none;

        margin: 5px;
        padding: 10px;
        width: 500px;
     }
  `]
})
export class TaskCardComponent implements OnInit {

  @Input()
  public task: Task = null;

  public taskUserName = '';

  constructor(private taskService: TaskService, private userService: UserService) {
  }

  ngOnInit(): void {
     this.userService.getAll().subscribe(userList => {
        this.taskUserName = userList.find(user => user.id === this.task.assigneeId)?.name ?? '';
     });
  }

  public changeTaskCompletion(): void {
     this.taskService.changeTaskCompletion(this.task, !this.task.completed);
  }
}

Svelte :

<script>
import { taskStore } from '../stores/task-store';
import { userStore } from '../stores/user-store';

export let task;

let taskUserName = '';

userStore.subscribe((userList) => {
 const user = userList.find((user) => user.id === task.assigneeId);
 taskUserName = user ? user.name : '';
});

function changeCompletion() {
 taskStore.changeTaskCompletion(task, !task.completed);
}
</script>

<style>
.task {
 background: aquamarine;
 border: 2px solid #cccccc;
 border-radius: 10px;

 list-style-type: none;

 margin: 5px;
 padding: 10px;
 width: 500px;
}
</style>

<li class="task">
<label for="completion-task-{task.id}">
 <input
  id="completion-task-{task.id}"
  type="checkbox"
  checked={task.completed ? "checked" : ""}
  on:change={changeCompletion} />
 {task.completed ? 'Completed' : 'In progress'}
</label>

<h1>{task.id} - {task.title}</h1>
<div>{taskUserName}</div>
</li>

Ici, seulement 11 lignes de moins chez Svelte qui en compte 38 contre 49 pour Angular.

Encore une fois, pas grand chose de significatif côté template. Il permet de voir comment chaque framework prend en charge les événements du DOM. Au-delà de la syntaxe, la grosse différence tient dans le fait qu’ Angular attend un appel de fonction, quand Svelte préfèrera lui une fonction ou une référence de fonction.

Côté script, on remarque encore bien plus de code boilerplate pour Angular. Ceci mis à part, pas beaucoup de différence, si ce n’est que le cas illustre comment sont déclarés les propriétés des composants dans Svelte (export let task)

TaskUpdate

Angular :

import { Component, OnInit } from '@angular/core';
import { TaskService } from '../shared/task-service';
import { Router } from '@angular/router';
import { UserService } from '../shared/user-service';
import { Task } from '../entities/task';
import { FormBuilder, FormGroup } from '@angular/forms';

@Component({
  selector: 'app-task-update',
  template: `
     <h1>Create a task</h1>

     <form [formGroup]="taskForm" (ngSubmit)="handleSubmit()">
        <div>
           <label for="title">Title</label>
           <input type="text" id="title" formControlName="title" />
        </div>
        <div>
           <label for="assigneeId">Assignee</label>
           <select id="assigneeId" formControlName="assigneeId">
              <option value="">None</option>
              <option *ngFor="let user of userService.getAll()|async" [value]="user.id">{{user.name}}</option>
           </select>
        </div>
        <input type="submit" />
     </form>
  `
})
export class TaskUpdateComponent {
  public taskForm: FormGroup;

  constructor(private taskService: TaskService, public userService: UserService, private router: Router, formBuilder: FormBuilder) {
     this.taskForm = formBuilder.group({
        assigneeId: '',
        title: ''
     });
  }

  public handleSubmit(): void {
     const formControls = this.taskForm.controls;
     this.taskService.addTask({
        assigneeId: formControls.assigneeId.value ? +formControls.assigneeId.value : null,
        title: formControls.title.value,
        completed: false
     });
     this.router.navigateByUrl('/').catch(error => console.log(`Errors while navigating : ${error}`));
  }
}

Svelte :

<script>
import { taskStore } from '../stores/task-store';
import { userStore } from '../stores/user-store';
import { push } from 'svelte-spa-router';
import { Task } from '../entities/task';

function handleSubmit() {
 taskStore.addTask(task);
 push('/');
}

let task = new Task();
</script>

<h1>Create a task</h1>

<form on:submit|preventDefault={handleSubmit}>
<div>
 <label for="name">Title</label>
 <input type="text" id="name" bind:value={task.title} />
</div>
<div>
 <label for="assigneeId">Assignee</label>
 <select id="assigneeId" bind:value={task.assigneeId}>
  <option value="">None</option>
  {#each $userStore as user}
   <option value={user.id}>{user.name}</option>
  {/each}
 </select>
</div>
<input type="submit" />
</form>

45 lignes côté Angular et 38 côté Svelte.

On n’a, ici, qu’un formulaire. Le choix, côté Angular, est d’utiliser un formulaire dirigé par le code.

On observe encore et toujours moins de code côté Svelte, toutefois cela cache un petit point. En effet, si Angular demande plus de mise en place pour les formulaires, il offre aussi plus de fonctionnalités. En effet on n’a, par exemple, pas de mécanisme de validation avec Svelte, et le mettre en place demanderait beaucoup de code, en tous cas sans librairie dédiée.

Ce composant permet de mettre en valeur la fonctionnalité de binding bidirectionnelle de Svelte. Il s’active par le simple préfixe bind. Cela permet de répercuter automatiquement toutes les modifications faites par le script vers le template et inversement.

En plus de cela, on remarque qu’il est possible d’ajouter des modificateurs à la capture d’évènements, directement dans le template.

Bilan

Résumons le compte de lignes, en incluant le composant App :

Angular Svelte
TaskService/TaskStore 32 lignes 31 lignes
UserService/UserStore 13 lignes 8 lignes
App 14 lignes 10 lignes
TaskList 32 lignes 18 lignes
TaskCard 49 lignes 38 lignes
TaskUpdate 45 lignes 31 lignes
Total 185 lignes 136 lignes

Au niveau comptable, on se rend compte que Svelte remplit la promesse qu’il avance. Toutefois un simple comptage de lignes n’est pas forcément une métrique très cohérente. Et j’ai volontairement exclu du calcul les classes de modules et de configuration requises par Angular. L’analyse des classes effectuée plus haut montre que Svelte apporte aussi des mécanismes très intéressants pour faciliter la vie du développeur, comme par exemple la gestion simplifiée des abonnements aux stores.

Mais avant de sauter à la conclusion, il est temps d’exécuter une dernière commande :

npm run build (ou ng build --prod)

Pour Angular, il ressort, entre autres, trois fichiers JavaScript, pour un total de 292,1 ko

Pour Svelte, un seul fichier… de 19 ko !

On obtient donc, pour Svelte, un résultat plus de 10 fois plus léger !

Comment expliquer une telle différence ? Tout simplement à la façon dont Svelte est conçu. Svelte est non seulement un framework, mais aussi un compilateur. Si, à première vue, il peut sembler saugrenu de vouloir compiler du JavaScript, cela apporte en fait un gros avantage.

En effet, les fichiers “de production” générés par Angular contiennent, en plus des composants qui auront été créés, toutes les fonctionnalités du framework, qu’elles soient utilisées ou pas.

La compilation permet de n’embarquer que ce qui sera utilisé, réduisant ainsi considérablement la taille du livrable. Cela apporte une réelle plus-value, par exemple pour le développement d’interface destinée à l’IoT, où le stockage peut être un point critique.

Conclusion

On l’a vu, dans cet exemple Svelte tient parfaitement ses promesses. Avec moins de lignes de code, on arrive à un résultat similaire, sans pour autant sacrifier à la “solidité” de l’application.

D’un niveau plus personnel, j’ai trouvé très agréable de travailler avec Svelte. En me basant uniquement sur leur très bon tutoriel, j’ai très vite eu l’impression de développer facilement des choses assez complexes. Et aujourd’hui, j’ai hâte de retravailler avec Svelte.

Alors doit-on laisser tomber Angular et tout passer à Svelte ? Évidemment que non. Malgré les avantages évidents qu’apporte ce dernier, on peut vouloir avoir plus la main sur la façon dont notre store sera géré par exemple.

En outre, si Svelte rencontre actuellement un grand succès, la communauté autour d’Angular est encore plus importante et le nombre de plugins disponibles l’est aussi.

Ayant prophétisé en 2009 que Facebook n’intéresserait personne, je ne m’avancerais pas à faire un pronostic sur l’avenir de Svelte. Toutefois je pense que les concepts qu’il amène, à commencer par la compilation du code JavaScript, sauront se diffuser à l’avenir, dans les frameworks existants ou à venir.

Pour aller plus loin

Si la communauté autour de Svelte n’est pas (encore) aussi vaste que celle d’Angular, les ressources sont déjà nombreuses et augmentent chaque jour.

Si vous voulez vous initier à Svelte, je ne saurais que trop vous conseiller le très bon tutoriel disponible sur le site officiel (https://svelte.dev/tutorial/basics)

Enfin, un article paru pendant la rédaction de cet article fera un parfait complément, si vous n’êtes toujours pas convaincu : https://dev.to/mhatvan/10-reasons-why-i-recommend-svelte-to-every-new-web-developer-nh3