Cours Angular · Module 6: Signals
Les feux de fumée - la première tour sur les murailles
Dans cette leçon6
Le panneau d'entraînement du deuxième module fonctionnait sans le moindre reproche. Tu cliquais sur un bouton, la méthode ajoutait des répétitions à un champ de la classe, et le nombre grandissait à l'écran. Un champ ordinaire, une affectation ordinaire, aucun tour de passe-passe - et c'est justement pour cela qu'une question dérangeante s'impose : qui, au juste, a prévenu Angular que ce nombre venait de changer ?
Personne. Un nombre logé dans un champ de classe est un nombre, et rien de plus. Il n'a pas de bouche, pas de messager, aucun moyen d'annoncer qu'il valait dix il y a un instant et qu'il en vaut douze maintenant. Angular s'en sort alors par la seule méthode qui lui reste : après chaque clic, après chaque échéance de minuterie et après chaque réponse du réseau, il parcourt l'arbre entier des composants et vérifie une à une toutes les expressions inscrites dans les templates, en comparant le résultat avec le précédent. Puisque personne ne rapporte rien, il faut faire le tour de tout le monde et demander.
À l'Académie, cette organisation porte un nom et se paie. Pendant des années, les nouvelles ont circulé entre les places fortes grâce à un messager : il partait au moindre remue-ménage, faisait le tour de tous les châteaux l'un après l'autre et demandait à chacun si quelque chose avait bougé. Avec trois châteaux, personne ne s'en plaignait. Avec cent, le messager est devenu le maillon le plus lent de tout le système - non parce qu'il courait mal, mais parce qu'il courait partout, y compris là où rien ne s'était produit depuis une semaine.
C'est pourquoi des tours à signaux se sont dressées sur les murailles. Une tour n'attend aucun messager. Quand le guetteur change ce que sa tour montre, la fumée monte immédiatement, et celui qui l'apprend est exactement celui qui regardait cette tour - personne d'autre. Dans Angular, une telle tour s'appelle un signal, un signal de fumée : une valeur qui sait elle-même qu'elle a changé et qui sait elle-même en avertir ceux qui la lisaient. Les signals sont apparus dans Angular 16, se sont stabilisés dans la dix-septième version, et dans la dix-neuvième, celle sur laquelle tu travailles, ils sont déjà la façon normale de conserver un état. Je te les ai mentionnés dans le premier module, au moment du tour d'horizon de ce qui distingue Angular aujourd'hui - aujourd'hui, tu allumeras ta première tour de tes propres mains.
Ce que tu vas apprendre
- dresser un signal avec la fonction
signal()et lui donner une valeur initiale - lire la valeur d'un signal et comprendre pourquoi la lecture s'écrit avec des parenthèses
- distinguer un signal d'un champ de classe ordinaire, y compris à ce qu'en dit le compilateur
- donner l'ordre
set(), c'est-à-dire fixer la valeur directement - lire les signals dans le template d'un composant exactement comme dans le code de la classe
- reconnaître les écritures qui ressemblent à des signals sans en être
Allumer la tour
La fonction signal() est fournie par le paquet @angular/core - celui-là même d'où tu tires le décorateur @Component depuis le premier module. Tu lui passes un seul argument : la valeur que la tour doit montrer au départ. Mettons-nous d'accord sur un exemple qui traversera la moitié de cette leçon. Le guetteur posté à la porte tient le niveau d'alerte : un nombre qui part de zéro tant que la voie est calme, et qui monte dès que les éclaireurs rapportent des nouvelles inquiétantes. Puisque nous commençons dans le calme, la valeur initiale sera zéro.
1import { signal } from '@angular/core';
2
3const alertLevel = signal(0);La tour est debout et montre zéro. Regarde bien ce qui manque dans cette ligne. Il n'y a pas de new, parce que signal n'est pas une classe mais une simple fonction qui construit un signal et le renvoie. Il n'y a pas de décorateur au-dessus d'un champ, parce qu'un signal n'est pas une annotation collée à une variable. Il n'y a enfin aucune déclaration de type, et pourtant TypeScript sait déjà qu'un nombre habite cette tour - il l'a déduit du zéro que tu as fourni. Dès cet instant, toute tentative d'y inscrire une chaîne de caractères se solde par une erreur de compilation, avant même que l'application ne démarre. C'est la même discipline que celle découverte dans le premier module autour du code Bushido, simplement rangée derrière un seul appel.
Retiens cet appel comme quatre morceaux posés toujours dans le même ordre : le nom signal, la parenthèse ouvrante, la valeur initiale, la parenthèse fermante. Rien avant le nom, rien entre la parenthèse et la valeur. Un signal qui compte à partir de zéro, c'est donc littéralement signal, puis (, puis 0, puis ).
Trois autres écritures paraissent tout aussi sensées et aucune ne fonctionne - je les ai passées une par une au compilateur. L'écriture new Signal(0) n'a aucune chance de compiler, car Signal dans @angular/core est un nom de type et non une classe : le compilateur répond en toutes lettres que Signal ne désigne qu'un type alors qu'il est employé comme une valeur. L'écriture createSignal(0) est un nom rapporté d'un tout autre framework, puisque @angular/core n'exporte aucune fonction de ce nom - le compilateur dit que le module n'a pas cet export, et l'inspection du paquet lui-même à l'exécution le confirme : le nom createSignal n'y est tout simplement pas. L'écriture @Signal() count = 0 transpose aux signals l'habitude des décorateurs que tu connais de @Component et @Injectable - sauf qu'Angular ne possède aucun décorateur de ce nom, si bien que le compilateur signale une erreur, que tu importes ou non le nom Signal. Il ne reste qu'un seul chemin, et c'est lui la bonne réponse : un simple appel de la fonction signal().
Pourquoi la lecture réclame des parenthèses
La tour est debout, mais personne ne l'a encore regardée. La lecture d'un signal s'écrit avec le nom suivi de parenthèses vides, ce qui surprend quiconque a l'habitude des champs de classe : cela ressemble à un appel de méthode, alors que tu ne calcules rien et ne modifies rien.
1console.log(alertLevel()); // 0Zéro s'est affiché, et c'est un résultat obtenu en exécutant le code, pas en fouillant ma mémoire. La raison des parenthèses est d'une simplicité désarmante et mérite d'être retenue pour tout le reste du module : un signal est réellement une fonction. Pas un objet avec un champ à l'intérieur, pas une variable qui se ferait passer pour un nombre - une fonction que tu appelles pour obtenir la valeur actuelle. Cet appel fait cependant une chose de plus, invisible au premier regard. S'il tombe à un endroit qu'Angular surveille, et le template d'un composant est précisément un tel endroit, la lecture enregistre une dépendance : Angular note que ce fragment de vue précis regarde cette tour précise. Grâce à cela, au prochain changement, il n'a plus besoin de faire le tour de tous les châteaux, puisqu'il sait qui réveiller. Je l'ai vérifié sur du code en fonctionnement : une valeur bâtie sur un signal ne s'est recalculée qu'après le changement de ce signal, et l'écriture dans un signal qu'elle n'avait jamais lu ne l'a pas fait bouger une seule fois. En dehors d'un tel endroit surveillé, la lecture est la plus ordinaire des récupérations de valeur - elle est synchrone, elle ne va rien chercher sur le réseau et n'attend aucune promesse, et trois appels d'affilée renvoient le même nombre sans rien consommer.
Puisqu'un signal est une fonction, on peut le vérifier de la manière la plus simple qui soit. L'opérateur typeof répond en un seul mot de quelle nature est une valeur : pour un nombre il dit "number", pour une fonction il dit "function". Posons-lui la question deux fois - une fois à propos de la tour elle-même, une fois à propos de ce que l'on en lit.
1console.log(typeof alertLevel); // function
2console.log(typeof alertLevel()); // numberLa première ligne dit "function", la seconde "number", et toute la réponse à la question des parenthèses tient dans cette différence. Sans parenthèses, tu tiens la tour entre les mains, avec les parenthèses ce que l'on voit à son sommet. Les deux résultats sortent de code exécuté. Remarque ce que cette vérification n'a pas changé : le signal est resté exactement le même, sa valeur vaut toujours zéro, et le simple fait de regarder la tour n'y remue rien.
Une légende circule à ce propos : il faudrait écrire les parenthèses sous peine de voir TypeScript refuser de compiler. C'est faux, et la vraie raison est plus intéressante. L'écriture console.log(alertLevel) sans parenthèses compile sans le moindre avertissement - je l'ai vérifié au compilateur - sauf que tu transmets alors la fonction elle-même au lieu du nombre. L'erreur n'apparaît qu'au moment où tu essaies d'utiliser la tour là où un nombre est attendu : l'affectation const x: number = alertLevel est rejetée par le compilateur, qui explique qu'un signal de nombre ne peut pas se substituer à un nombre. Les parenthèses ne sont donc pas imposées par le compilateur - elles découlent de ce qu'est un signal.
Reste le dernier mythe, le plus innocent : un signal ne serait qu'une variable rangée quelque part en mémoire, et les parenthèses une simple décoration. Si c'était le cas, on pourrait en extraire la valeur par l'un des chemins habituels - un champ value, une méthode get() ou une méthode read(). Vérifions donc si l'un de ces trois noms existe seulement.
1console.log(typeof alertLevel.value); // undefined
2console.log(typeof alertLevel.get); // undefined
3console.log(typeof alertLevel.read); // undefinedTrois fois "undefined", et c'est un résultat de code exécuté : aucun de ces noms ne se trouve dans le signal. Le compilateur est encore plus direct, car pour chacun d'eux il répond que cette propriété n'existe pas sur un signal de nombre. Le nom value n'est pas tombé du ciel - c'est ainsi que l'on lit une valeur dans la réactivité de Vue, tandis que get() et read() sont des réflexes rapportés d'autres bibliothèques. Dans Angular, la lecture est unique et s'écrit avec deux parenthèses vides après le nom du signal. Et rien n'a bougé dans la tour au passage : ces trois questions n'ont rien fixé et rien effacé.
Le premier ordre : fixer la valeur
Une tour qui montre zéro pendant toute sa garde n'est qu'un ornement de muraille. L'éclaireur revient du col et rapporte qu'un détachement étranger a été aperçu sur la voie : il faut donc relever le niveau d'alerte. Le réflexe souffle l'écriture connue des champs de classe, à savoir l'affectation avec le signe égal - et avec un signal, ce réflexe est faux de deux manières à la fois. L'écriture alertLevel = 2 ne change pas ce que la tour montre : elle remplace la tour entière par le nombre deux, et détruit donc le signal au lieu de le mettre à jour. En pratique, tu n'iras même pas jusque-là, car le compilateur rejette cette ligne en expliquant qu'un nombre ne peut pas se substituer à un signal de nombre - je l'ai essayé sur un champ de classe et l'erreur tombe immédiatement.
C'est la méthode set() qui sert à changer la valeur. Tu l'appelles sur le signal et tu lui donnes un seul argument : la nouvelle valeur, toute prête. Le contenu précédent de la tour n'a ici aucune importance, personne ne va même le lire. C'est un ordre sans appel, du genre "il en faudra deux, fin de la discussion".
1alertLevel.set(2);
2
3console.log(alertLevel()); // 2La tour montre deux. Le plus important reste pourtant ce que cet ordre n'a pas touché : alertLevel est toujours le même signal, la même fonction, la même entité en mémoire - je l'ai vérifié en gardant une référence vers lui avant l'écriture et en la comparant après. Seule la valeur logée à l'intérieur a été échangée, si bien que tout endroit de l'application qui garde ce nom verra le nouveau nombre immédiatement, sans avoir à le réclamer. Tu n'as pas non plus eu à prévenir qui que ce soit : il n'y a ici aucun appel du genre "rafraîchis la vue" ni aucun événement à émettre. Angular a noté l'écriture tout seul et réveillera tout seul ceux qui lisaient cette tour. La méthode, elle, ne renvoie rien - le résultat de set() est undefined, alors n'essaie pas de l'affecter à quoi que ce soit.
Lis cette écriture de gauche à droite, car l'ordre des morceaux est toujours le même : le nom du signal, le point, le mot set, et pour finir la parenthèse avec la nouvelle valeur à l'intérieur. Pour une tour nommée count et une valeur de dix, cela donnerait count.set(10) - d'abord count, puis ., puis set, puis (10). Mon conseil : partout où la nouvelle valeur est tout simplement connue - zéro après une promotion, true après une connexion, la ligne choisie dans une liste - prends set() sans chercher plus loin. Cet ordre se lit en une demi-seconde et ne peut pas être mal compris.
Il existe pourtant des ordres qu'on ne peut pas donner sans regarder la tour. "Monte l'alerte d'un cran" ne désigne aucun nombre précis tant que tu ignores ce que la tour affiche à cet instant. Pour ces situations, le signal dispose d'une seconde méthode, update(), qui reçoit non pas une valeur toute prête mais une fonction chargée de calculer la nouvelle valeur à partir de l'ancienne. Tu la verras dans un instant dans le code du composant, et nous nous en occuperons sérieusement au prochain relais - avec la question de savoir lequel de ces deux ordres convient à quel moment.
La tour dans un composant
Toutes les lignes vues jusqu'ici étaient de simples constantes dans un fichier, afin que la mécanique reste visible sans échafaudage autour. Dans une vraie application, les signals sont des champs de la classe d'un composant. Bâtissons le panneau de statistiques d'un samouraï : il affichera le nom du guerrier, son niveau et l'expérience acquise, et deux boutons permettront de faire bouger ces nombres. Commençons par les champs seuls, sans décorateur et sans template.
1export class SamuraiStatsComponent {
2 name = signal('Musashi');
3 level = signal(1);
4 experience = signal(0);
5}Trois tours, et trois contenus différents à leur sommet : une chaîne portant le nom, le nombre du niveau et le nombre de l'expérience. La façon de les dresser n'apporte rien de nouveau - c'est exactement la même fonction signal() que pour le niveau d'alerte, simplement appelée trois fois et affectée à des champs plutôt qu'à des constantes. Un signal n'exige pas d'avoir un nombre à l'intérieur : une chaîne, un booléen, un objet ou un tableau y tiennent tout aussi bien. Regarde ce qui manque dans cette classe : pas de constructeur, pas de méthode ngOnInit, aucune liste de champs à observer. Les rituels du cycle de vie découverts dans le deuxième module ne servent à rien ici, car le signal naît en même temps que l'objet de la classe et possède une valeur dès le premier instant.
Le panneau doit encore afficher un nombre : combien d'expérience il manque pour atteindre le niveau suivant. Personne ne saisit cette valeur à la main, puisqu'elle découle des deux autres - du niveau et de l'expérience acquise. Dans Angular, on la fabrique avec une troisième fonction du paquet @angular/core, nommée computed(), et notre champ s'appellera xpToNextLevel. Aujourd'hui tu ne le croiseras qu'en passant, et seulement du côté de la lecture : il se lit avec des parenthèses, exactement comme n'importe quel autre signal. D'où vient sa valeur et pourquoi personne ne la fixe jamais - c'est le sujet de toute la leçon suivante.
1template: `
2 <h2>{{ name() }}</h2>
3 <p>Niveau : {{ level() }}</p>
4 <p>Experience : {{ experience() }} XP</p>
5 <p>XP jusqu'au niveau suivant : {{ xpToNextLevel() }}</p>
6`Le template lit les quatre champs de la même manière : le nom, des parenthèses vides, le tout entre les doubles accolades de l'interpolation que tu connais du deuxième module. Pour le template, xpToNextLevel() ne se distingue en rien de level() - les deux lignes donnent simplement un nombre. Pas une seule règle de l'interpolation n'a changé au passage : accolades, expression, terminé. Il n'y a pas non plus ici la moindre ligne qui veillerait au rafraîchissement - dans un template, tu n'écris pas que quelque chose doit être redessiné, mais à partir de quoi cela doit l'être. La seule nouveauté par rapport à un champ ordinaire, ce sont ces deux parenthèses ; si tu les omettais, c'est une fonction qui atterrirait entre les accolades au lieu d'un nombre, et l'utilisateur verrait à l'écran quelque chose que personne n'avait l'intention de lui montrer.
Restent les deux boutons. Le premier ajoute cent points d'expérience, le second monte le niveau et remet le compteur d'expérience à zéro. La première méthode calcule la nouvelle valeur à partir de l'ancienne : c'est donc l'update() annoncé à l'instant qui lui revient. La seconde se termine par un ordre que tu connais déjà parfaitement : set(0), autrement dit "ce sera zéro, quel qu'ait été le total avant la promotion".
1gainExperience(amount: number): void {
2 this.experience.update(xp => xp + amount);
3}
4
5levelUp(): void {
6 this.level.update(lvl => lvl + 1);
7 this.experience.set(0);
8}À l'intérieur de la classe, les signals sont des champs : tu les atteins donc à travers this - c'est l'erreur la plus fréquente quand on recopie les exemples de la documentation, où les signals sont souvent de simples constantes au niveau du fichier. Après un clic sur le second bouton, le niveau augmente de un et l'expérience revient à zéro. Rien d'autre ne change au passage que ces deux nombres : aucune des deux méthodes ne touche au champ name, personne ne reconstruit le composant et personne n'ordonne à Angular de rafraîchir la vue. Remarque aussi que levelUp donne deux ordres distincts à deux tours distinctes - une tour, c'est une valeur, et il n'existe aucun moyen d'en changer deux d'un seul appel.
Rassemblons maintenant toute la tour. Le décorateur @Component t'est connu depuis le premier module : le champ selector est le nom de la balise, standalone: true marque un composant autonome, template porte le template. Tu connais aussi la liaison d'événement (click) du deuxième module. Seuls sont nouveaux les quatre champs et les deux méthodes que tu viens d'assembler séparément.
1import { Component, signal, computed } from '@angular/core';
2
3@Component({
4 selector: 'app-samurai-stats',
5 standalone: true,
6 template: `
7 <h2>{{ name() }}</h2>
8 <p>Niveau : {{ level() }}</p>
9 <p>Experience : {{ experience() }} XP</p>
10 <p>XP jusqu'au niveau suivant : {{ xpToNextLevel() }}</p>
11
12 <button (click)="gainExperience(100)">+100 XP</button>
13 <button (click)="levelUp()">Level Up!</button>
14 `
15})
16export class SamuraiStatsComponent {
17 name = signal('Musashi');
18 level = signal(1);
19 experience = signal(0);
20
21 xpToNextLevel = computed(() => this.level() * 1000 - this.experience());
22
23 gainExperience(amount: number): void {
24 this.experience.update(xp => xp + amount);
25 }
26
27 levelUp(): void {
28 this.level.update(lvl => lvl + 1);
29 this.experience.set(0);
30 }
31}Le panneau entier tient en une vingtaine de lignes, et pas une seule n'a été consacrée au rafraîchissement de l'écran. La liste des imports venus de @angular/core a grandi d'exactement deux noms par rapport aux composants des premiers modules - signal et computed - et c'est là tout le prix d'entrée dans la réactivité. Un clic sur le bouton "+100 XP" change une seule tour, experience, et cela suffit : Angular sait que deux lignes du template la regardent, celle du nombre de XP et celle de l'expérience manquante, et il s'occupera exactement de celles-là. Rien ne change au passage dans le titre portant le nom ni dans la ligne du niveau, car aucune des deux ne lisait cette tour. Remarque enfin qu'il n'y a dans la classe aucune ligne chargée de maintenir la cohérence - personne ne soustrait à la main l'expérience du seuil de promotion, et pourtant le dernier nombre du template est toujours juste.
Ce que tu sais déjà, et ce qui attend derrière la porte
Tu as tout ce qu'il faut pour ta première garde sur une tour. La fonction signal() dresse la tour et lui donne une valeur initiale, les parenthèses vides montrent ce qui y brûle à cet instant, et la méthode set() donne l'ordre "il en faudra tant". Cela suffit à faire passer aux signals chaque compteur, chaque drapeau et chaque champ de formulaire de ton application, et à ce qu'aucun d'eux n'ait plus besoin d'un messager faisant le tour de tous les châteaux. Trois choses te manquent encore, et chacune aura son relais : calculer une nouvelle valeur à partir de l'ancienne, avec les valeurs qui se recalculent toutes seules, réagir à un changement par quelque chose venu de l'extérieur de l'écran, une ligne de journal par exemple, et enfin recevoir des signals venus du composant parent.
Retiens ceci : un signal est une tour, pas un nombre - les parenthèses montrent ce qui brûle à son sommet en cet instant, et set() est l'ordre après lequel la fumée monte sans demander l'accord du messager.
Tu as repéré une erreur dans cette leçon ?
Teste tes connaissances
Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.
1. Comment crée-t-on un signal basique dans Angular ?
2. Pourquoi la lecture de la valeur d'un signal nécessite-t-elle des parenthèses, par exemple count() ?
Voici 2 des 3 questions de cette leçon. Réponds aux autres dans le jeu.
Tâches pratiques dans le jeu
- Clique dans l'ordre
Ordonne l'appel qui crée un signal avec une valeur initiale de 0.
- Éditeur de code
Complète le composant compteur : le bouton +1 doit calculer la nouvelle valeur du signal à partir de la précédente, et Reset doit la remettre à zéro.
- Tri horizontal
Ordonne, sur une seule ligne, l'instruction qui définit la valeur du signal count à 10.