Angular et les signals

On a vu apparaître en 2023, sur de nombreuses librairies JavaScript, les signaux (ou signals en anglais). Il s’agit d’une nouvelle primitive qui va nous permettre de mieux gérer la réactivité dans nos applications. Nous étudierons ici le cas d’Angular qui les a progressivement introduits depuis la version 17.

Ce bon vieux Zone.js

Pour expliquer pourquoi ils peuvent être utiles à nos applications Angular il va falloir s’attarder un peu sur le fonctionnement de ce framework.

Si vous pratiquez Angular depuis un certain temps, vous avez sûrement entendu parler de zone.js. Pour que notre application soit réactive, on a besoin de calculer l’état de nos composants à partir des interactions extérieures (utilisateur, retours du serveur etc…). Par exemple, un clic de l’utilisateur sur le bouton connexion entraîne l’ouverture d’un modal. Zone.js est là pour gérer cette réactivité : il capture toutes les interactions (telles qu’un clic) pour en informer Angular. Angular se chargera ensuite de recalculer le nouvel état de l’application (le nouveau DOM). On appelle cela la “change detection strategy”. Chaque framework a sa propre stratégie (pour les curieux je vous invite à aller lire les renders de React avec son virtual DOM)

Le problème avec zone.js c’est qu’il va parcourir sans cesse une bonne partie de notre application à la recherche de nouveaux changements. Plus votre application grandit, plus ce parcours est fastidieux.

C’est à ce moment que les signaux peuvent nous être utiles. Il vont nous permettre de suivre les changements d’états de nos données d’entrée pour directement déclencher l’effet demandé.

Une architecture basée sur les signaux nous permettra de mettre à jour notre DOM uniquement lors du changement d’un de ses signaux. On se passera à terme de Zone.js et des problèmes de performances qu’il entraîne.

L’apprentissage par la pratique avec le jeu de paires

Pour illustrer par un exemple nous allons recréer un jeu memory ou jeu de paires en français. Ceux pour qui les doux souvenirs de leur enfance sont trop loin, le but du jeu est de retrouver des paires semblables en faisant appel à votre mémoire.

Déroulement de la partie

  • En début de partie, l’ensemble des cartes sont disposées face cachée
  • On retourne deux cartes : si elles sont identiques, elles restent retournées
  • Le but du jeu est d’avoir toutes les cartes retournées

Configuration de base

La version d’Angular (18) lors de la rédaction de cet article n’est pas configurée par défaut pour exécuter votre application sans zone.js. Il vous faudra ajouter ces deux étapes après avoir créé votre app via angular cli :

  • On configure Angular en lui indiquant de ne plus lancer zone.js pour détecter les changements (on y gagnera en performance) :
import {
    ApplicationConfig,
    provideZoneChangeDetection,
    provideExperimentalZonelessChangeDetection
    } from "@angular/core";
import { provideRouter } from "@angular/router";
import { routes } from "./app.routes";
export const appConfig: ApplicationConfig = {
  providers: [
    // provideZoneChangeDetection({ eventCoalescing: true }), (ancien paramètre avec zoneJS)
    provideExperimentalZonelessChangeDetection(),
    provideRouter(routes),
  ],
};

app.config.ts

  • On supprime zone.js de la liste de vos polyfills dans votre angular.json (et de votre package.json), ce qui allégera la taille de votre bundle :
"build": {
    "builder": "@angular-devkit/build-angular:application",
    "options": {
        "outputPath": "dist/memory-signals",
        "index": "src/index.html",
        "browser": "src/main.ts",
        "polyfills": [
          // "zone.js"
        ],
    }
}

angular.json

Concernant l’architecture, on peut s’imaginer un composant desk qui appelle toutes les cartes dans une boucle for. Toutes les informations seront regroupées dans la variable cards qui sera un tableau contenant toutes les cartes.

Les bases des signaux :

Déclaration

Un signal se déclare comme une variable dans une classe :

tries = signal<number>(0);

desk.component.ts

Pour obtenir la valeur d’un signal on l’exécute :

console.log(`essai numéro ${tries()}`)

Si notre signal est modifiable il aura deux fonctions pour le modifier :

reset() {
	this.tries.set(0);
}
increment() {
	this.tries.update(t => t + 1);
}

Input

Les décorateurs @Input se verront à terme remplacés par des “inputs signals”.

Pour une carte, on pourra lui passer la valeur visible et son emoji :

visible = input.required<boolean>();
emoji = input.required<string>();

card.component.ts

Cette carte ne sera re-rendue que lorsque l’on changera “visible” ou “emoji”. 

Il faudra changer nos habitudes car contrairement aux décorateurs @Input, les signaux engendrés par input() ne seront pas modifiables (on parle de readOnlySignal) 

Pas de changement de comportement lors de l’appel du composant, on lui passe directement une valeur et non un signal :

@for (card of cards(); track index; let index = $index; ) {
  <app-card
      (click)="onClick(index)"
      [emoji]="card.emoji"
      [visible]="card.attempting || card.pairFound"
  ></app-card>
}

card.component.html

Output

La syntaxe remplaçant les @Output est différente dans l’initialisation mais l’utilisation est semblable :

triesToWin = output<number>()

desk.component.ts

On obtient un objet qui aura une méthode emit :

this.triesToWin.emit(this.tries());

desk.component.ts

Cas particulier du model

La fonction model n’existait pas avant l’arrivée des signaux. Elle permet de gérer plus simplement le double data binding (le fait de pouvoir modifier le même état à partir de plusieurs composants). Cette méthode est beaucoup moins verbeuse que l’ancienne syntaxe avec @Input et @Output utilisés conjointement.

On peut ajouter à notre application, un chronomètre qui pourra être lancé : 

  • du composant lui même (via un bouton pour le contrôler)
  • de son composant parent, lorsque le jeu reprend (le joueur retourne une nouvelle carte)
runningStopwatch = signal<boolean>(true);

app.component.ts : déclaration de notre signal dans le parent (composant principal)

Lorsqu’on passe un model en paramètre, dans le template, on lui donne directement le signal :

<div class="app">
  <h1>Memory</h1>
  <app-desk
    (click)="runningStopwatch.set(true)"
    (triesToWin)="congratulate($event)"
  ></app-desk>
  <app-stopwatch
    [(runningStopwatch)]="runningStopwatch">
  </app-stopwatch>
</div>

app.component.html : appel du composant enfant dans le template

runningStopwatch = model.required<boolean>();

handleClick() {
  this.runningStopwatch.update((r) => !r);
}

stopwatch.component.ts : déclaration du modèle dans le composant enfant et ajout d’une méthode attachée au bouton de contrôle du chronomètre

Les models nous permettent de mettre à jour un signal (via sa méthode set) depuis des composants différents.

Les fonctionnalités avancées des signaux

A part la syntaxe, rien de bien nouveau jusqu’ici vous me direz. Mais la puissance des signaux vient des effets que l’on va pouvoir déclencher :

La fonction computed

On peut créer un signal à partir d’un ou plusieurs autres. Ils seront mis à jour uniquement lorsque leurs signaux changent. A noter : ces signaux ne seront pas modifiables.

private foundCards = computed(() => this.cards().filter((c) => c.pairFound));

desk.component.ts

La fonction effect

Elle se place dans le constructeur de notre composant et elle est déclenchée uniquement lorsque les signaux dont elle dépend sont mis à jour. On peut par exemple déclencher la fin de partie :

constructor() {
  effect(() => {
    if (this.foundCards().length === this.cards().length) {
      this.triesToWin.emit(this.tries());
    }
  });
}

desk.component.ts

L’idéal serait de vérifier dans un effect si deux cartes retournées sont les mêmes mais cela impliquerait une mise à jour du signal card dans un effect déclenché lui-même par la mise à jour de card…

Sans prendre de précautions, on peut facilement arriver dans une boucle infinie. On choisira donc de déclencher cette vérification à chaque clic sur une carte via la méthode checkMatchingPair :

private checkMatchingPair(): void {
  const [pairA, pairB] = this.cards().filter(c => c.attempting)
  if (!pairA || !pairB) return;
  if (pairA.emoji === pairB.emoji) {
    // le joueur a trouvé la bonne paire :
    this.cards.update((cards) =>
      cards.map((card) =>
        card.attempting
          ? { ...card, attempting: false, pairFound: true }
          : card
       )
    );
  } else {
    // on laisse 1s au joueur pour visualisaer
    // des deux cartes retournées :
    setTimeout(() => {
      this.cards.update((cards) =>
        cards.map((c) => ({ ...c, attempting: false }))
      );
    }, 1000);
  }
}

desk.component.ts

Pour les plus curieux, je vous laisse aller consulter le github de l’application avec et sans les signaux.

Conclusion

L’arrivée des signaux dans Angular nécessitera un temps d’adaptation car leur gestion est différente de l’ancienne approche.

Même si l’impact des signaux sur les performances reste aujourd’hui difficile à mesurer, on appréciera l’expérience de développement qu’ils apportent. La syntaxe étant beaucoup plus concise, la maintenance de nos applications s’en trouvera simplifiée.

Les signaux ont été introduits par ECMA afin d’uniformiser les pratiques JavaScript. Cette démarche va donc plus loin que les avantages évoqués dans cet article : Il est aussi possible de les utiliser avec React (via Preact) et SolidJS. Leur adoption permettra de créer des passerelles entre les gros frameworks, et nous faciliteront le passage de l’un à l’autre.