Cours Angular · Module 2: Composants

La liaison de données - comment la classe parle au template

9 min de lecture
Dans cette leçon5

Quand le samouraï encaisse un coup et que sa santé passe de 100 à 70, l'écran doit afficher 70 et non l'ancienne valeur. Quand l'élève clique sur le bouton d'attaque, la classe doit l'apprendre pour retirer des points de vie à l'adversaire.

Sans l'aide du framework, tu devrais tout faire à la main : chercher les éléments de la page, réécrire leur texte après chaque changement de données et brancher des écouteurs de clic. Chaque étape oubliée donne un écran qui ment. Angular le fait pour toi grâce à la liaison de données (data binding). Dans le template, tu décris une seule fois ce qui est relié à quoi, et Angular veille à ce que la classe et la vue disent toujours la même chose.

Sur les champs de bataille d'autrefois, le commandant communiquait avec ses troupes par des bannières et des tambours. La bannière portait l'ordre du commandant aux soldats, et le tambour renvoyait le rapport dans l'autre sens. La liaison de données fonctionne de manière semblable et existe sous quatre formes :

SyntaxeDirectionExemple
{{ expression }}classe → template (texte){{ name }}
[propriété]="expression"classe → template (propriété de l'élément)[disabled]="isDefeated"
(événement)="instruction"template → classe(click)="attack()"
[(ngModel)]="champ"dans les deux sens[(ngModel)]="samuraiName"

Voyons-les une par une, en commençant par la plus simple.

L'interpolation - le message sur la bannière

L'interpolation insère une valeur de la classe dans le texte de la page. Tu écris l'expression entre doubles accolades, et Angular l'évalue puis affiche le résultat à cet endroit :

1<h2>{{ name }}</h2>
2<p>Niveau : {{ level }}</p>
3<p>Force du coup : {{ level * 10 }}</p>
4<p>{{ name.toUpperCase() }}</p>

Les accolades peuvent contenir un nom de propriété, un calcul ou un appel de méthode. Il doit cependant s'agir d'une expression, c'est-à-dire de quelque chose qui renvoie une valeur, et non d'une instruction. {{ level = 5 }} ne compile pas, car Angular interdit les affectations dans l'interpolation. Les accolades servent à montrer les données, pas à les modifier.

Le résultat de l'interpolation arrive toujours sur la page sous forme de texte. Si le champ name contient une balise <b>, l'élève la voit telle quelle au lieu d'un texte en gras. C'est une protection voulue : des données saisies par un utilisateur ne peuvent pas injecter ainsi leur propre HTML dans la page. Quand la valeur change dans la classe, Angular remplace le texte à l'écran tout seul.

La liaison de propriété - fixer des propriétés

L'interpolation insère du texte, mais il faut parfois fixer une propriété d'un élément : l'adresse d'une image, l'état d'un bouton ou une classe CSS. C'est le rôle de la liaison de propriété (property binding) : le nom de la propriété entre crochets et une expression après le signe égal :

1<img [src]="bannerUrl" [alt]="name">
2<button [disabled]="isDefeated">Attaquer !</button>
3<li [class.active]="isActive">Musashi</li>

Les crochets disent à Angular : évalue ce qui se trouve entre les guillemets comme une expression. [src]="bannerUrl" fixe la propriété src de l'image à la valeur du champ bannerUrl de la classe. [disabled]="isDefeated" désactive le bouton tant que isDefeated vaut true. La forme spéciale [class.active] ajoute la classe CSS active quand l'expression est vraie et la retire quand elle est fausse.

Sans crochets, la partie droite est une simple chaîne. C'est là qu'un piège attend beaucoup de débutants, alors compare ces deux boutons :

1<!-- bouton TOUJOURS désactivé : l'attribut disabled existe -->
2<button disabled="false">Attaquer !</button>
3
4<!-- bouton activé : la propriété reçoit la valeur false -->
5<button [disabled]="false">Attaquer !</button>

Dans la première ligne, false n'est que le texte de l'attribut, et pour le navigateur seule la présence de l'attribut disabled compte, donc le bouton reste désactivé. Dans la seconde, Angular évalue l'expression false et donne à la propriété de l'élément une vraie valeur booléenne, donc le bouton fonctionne. C'est pourquoi tout ce qui doit changer avec les données se lie entre crochets.

Pour du texte, les crochets ne sont pas nécessaires. [title]="'Musashi'" et title="Musashi" produisent exactement le même effet, car dans le premier cas l'expression est simplement une chaîne entre apostrophes. Les crochets deviennent indispensables quand la valeur vient de la classe ou n'est pas du texte, par exemple avec le nombre [level]="10", que tu transmettras à un composant enfant dans la dernière leçon du module.

La liaison d'événement - un rapport du champ de bataille

La liaison de propriété transmet des données de la classe au template. La liaison d'événement (event binding) fonctionne dans l'autre sens : quand quelque chose se produit sur la page, comme un clic, Angular appelle la méthode de la classe que tu as indiquée. Tu écris le nom de l'événement entre parenthèses :

1<button (click)="attack()">Attaquer !</button>
2<input (input)="onNameInput($event)">

La première ligne appelle la méthode attack() à chaque clic. La seconde réagit à chaque modification du texte dans le champ et passe à la méthode la variable spéciale $event. Pour les événements du navigateur, $event est l'objet de l'événement, dans lequel tu peux lire par exemple l'élément où l'événement s'est produit :

1export class SamuraiComponent {
2  name = 'Musashi';
3
4  onNameInput(event: Event): void {
5    const field = event.target as HTMLInputElement;
6    this.name = field.value;
7  }
8}

La propriété event.target désigne le champ de texte, et as HTMLInputElement indique à TypeScript qu'il s'agit bien d'un élément input, dont on peut donc lire la value. Une fois le nouveau nom affecté, Angular rafraîchit tous les endroits du template qui affichent name.

Méfie-toi de trois notations qui se ressemblent mais ne sont pas des liaisons d'événement Angular. [click]="attack()" est une liaison de propriété : Angular traite click comme une propriété de l'élément et non comme un événement. onclick="attack()" est un simple attribut HTML qui ne voit pas les méthodes de ta classe. @click="attack()" est la syntaxe de Vue, qu'Angular ne comprend pas. Dans Angular, un événement s'écrit toujours entre parenthèses.

La liaison bidirectionnelle - une conversation dans les deux sens

Un champ de formulaire a besoin des deux directions à la fois. La classe doit afficher le nom actuel du samouraï dans le champ, et quand l'élève en tape un nouveau, la classe doit le connaître aussitôt. C'est ce que relie la liaison bidirectionnelle (two-way binding), qui s'écrit avec des parenthèses à l'intérieur de crochets : [(ngModel)]. La directive ngModel vient de FormsModule, donc le composant doit l'avoir dans sa liste imports :

1import { Component } from '@angular/core';
2import { FormsModule } from '@angular/forms';
3
4@Component({
5  selector: 'app-samurai-form',
6  standalone: true,
7  imports: [FormsModule],
8  template: `
9    <input [(ngModel)]="samuraiName">
10    <p>Nom du samouraï : {{ samuraiName }}</p>
11  `
12})
13export class SamuraiFormComponent {
14  samuraiName = '';
15}

Chaque lettre tapée dans le champ va directement dans la propriété samuraiName, et l'interpolation du paragraphe affiche la nouvelle valeur. Cela marche aussi dans l'autre sens : si une méthode de la classe affecte un nouveau nom à samuraiName, le champ de texte l'affiche tout seul. Sans FormsModule dans la liste imports, le compilateur signale qu'il ne peut pas lier ngModel, parce que l'élément input n'a pas de telle propriété.

Les développeurs appellent cette notation la banane dans la boîte : la banane, ce sont les parenthèses, posée dans la boîte, les crochets. C'est un raccourci qu'Angular développe en deux liaisons que tu connais déjà :

1<input [ngModel]="samuraiName" (ngModelChange)="samuraiName = $event">

La liaison de propriété [ngModel] écrit la valeur de la classe dans le champ, et la liaison d'événement (ngModelChange) réaffecte la nouvelle valeur de $event à la classe à chaque modification. Les deux notations fonctionnent à l'identique, mais le raccourci se lit plus vite et laisse moins de place aux erreurs. L'ordre des signes compte : la forme inversée ([ngModel]) n'est pas une liaison bidirectionnelle, et le compilateur d'Angular avertit que les parenthèses doivent se trouver à l'intérieur.

Exemple complet - le panneau d'entraînement

Réunissons les quatre liaisons dans un seul composant. Le panneau d'entraînement permet de saisir un nombre de répétitions, d'effectuer un entraînement et de suivre le total de toutes les répétitions :

1import { Component } from '@angular/core';
2import { FormsModule } from '@angular/forms';
3
4@Component({
5  selector: 'app-training',
6  standalone: true,
7  imports: [FormsModule],
8  template: `
9    <div class="training-panel">
10      <h2>{{ trainingName }}</h2>
11
12      <input [(ngModel)]="reps" type="number">
13      <button
14        [disabled]="reps <= 0"
15        (click)="doTraining()">
16        S'entraîner !
17      </button>
18
19      <p>Répétitions effectuées : {{ totalReps }}</p>
20    </div>
21  `
22})
23export class TrainingComponent {
24  trainingName = 'Entraînement au sabre';
25  reps = 10;
26  totalReps = 0;
27
28  doTraining(): void {
29    this.totalReps += this.reps;
30  }
31}

Suis ce code pas à pas. L'interpolation {{ trainingName }} affiche le nom de l'entraînement dans le titre. La liaison bidirectionnelle [(ngModel)]="reps" relie le champ numérique à la propriété reps, donc chaque nombre saisi arrive directement dans la classe. La liaison de propriété [disabled]="reps <= 0" désactive le bouton quand le nombre de répétitions est nul ou négatif. La liaison d'événement (click)="doTraining()" appelle la méthode qui ajoute reps à totalReps, et l'interpolation du dernier paragraphe affiche le nouveau total.

Remarque ce que cette classe ne fait pas : elle ne cherche aucun élément de la page et ne réécrit pas le texte du paragraphe. Elle ne modifie que ses propres propriétés ordinaires, et les liaisons s'occupent du reste. Mon conseil : garde la logique dans les méthodes de la classe et contente-toi de l'appeler depuis le template, car de longs calculs écrits entre les guillemets du template deviennent vite illisibles.

Dans la dernière leçon de ce module, tu transmettras des données à un composant enfant par liaison de propriété, et dans le sixième module, tu construiras ce même panneau avec des signaux.

La salle d'entraînement ci-dessous contient le même composant que dans la première leçon. Trouve sa section « Démo de liaison de données » : l'interpolation du nom et du clan du samouraï, la liaison de propriété [value] de la barre de santé, la liaison d'événement des boutons Attaquer ! et Soigner, et la liaison bidirectionnelle du champ du nom.

Code de cette leçon : src/app/app.component.ts
1import { Component } from '@angular/core';
2import { FormsModule } from '@angular/forms';
3
4interface Samurai {
5  id: number;
6  name: string;
7  clan: string;
8  level: number;
9  health: number;
10}
11
12@Component({
13  selector: 'app-root',
14  standalone: true,
15  imports: [FormsModule],
16  template: `
17    <div class="dojo-training">
18      <header>
19        <h1>Salle d'entraînement du Dojo</h1>
20        <p>Module 2 : les composants Angular</p>
21      </header>
22
23      <section class="training-section">
24        <h2>Démo de liaison de données</h2>
25
26        <!-- Interpolation -->
27        <div class="demo-box">
28          <h3 ngNonBindable>Interpolation {{ }}</h3>
29          <p>Nom : {{ samurai.name }}</p>
30          <p>Clan : {{ samurai.clan }}</p>
31        </div>
32
33        <!-- Liaison de propriété -->
34        <div class="demo-box">
35          <h3>Liaison de propriété [ ]</h3>
36          <progress [value]="samurai.health" max="100"></progress>
37          <p>Santé : {{ samurai.health }} %</p>
38        </div>
39
40        <!-- Liaison d'événement -->
41        <div class="demo-box">
42          <h3>Liaison d'événement ( )</h3>
43          <button (click)="attack()">Attaquer !</button>
44          <button (click)="heal()">Soigner</button>
45        </div>
46
47        <!-- Liaison bidirectionnelle -->
48        <div class="demo-box">
49          <h3>Liaison bidirectionnelle [( )]</h3>
50          <input [(ngModel)]="samurai.name" placeholder="Nom du samouraï">
51          <p>Nom actuel : {{ samurai.name }}</p>
52        </div>
53      </section>
54
55      <section class="control-flow-section">
56        <h2>Démo du flux de contrôle</h2>
57
58        <!-- @if -->
59        <div class="demo-box">
60          <h3>&#64;if / &#64;else</h3>
61          @if (samurai.health > 50) {
62            <p class="status good">Samouraï en pleine forme !</p>
63          } @else if (samurai.health > 20) {
64            <p class="status warning">Samouraï affaibli</p>
65          } @else {
66            <p class="status danger">Samouraï dans un état critique !</p>
67          }
68        </div>
69
70        <!-- @for -->
71        <div class="demo-box">
72          <h3>&#64;for</h3>
73          <ul>
74            @for (s of samuraiList; track s.id) {
75              <li [class.selected]="s.id === selectedId" (click)="selectSamurai(s.id)">
76                {{ s.name }} (niv. {{ s.level }})
77              </li>
78            } @empty {
79              <li>Aucun samouraï</li>
80            }
81          </ul>
82        </div>
83
84        <!-- @switch -->
85        <div class="demo-box">
86          <h3>&#64;switch</h3>
87          @switch (getRank(samurai.level)) {
88            @case ('master') {
89              <span class="badge master">Maître</span>
90            }
91            @case ('advanced') {
92              <span class="badge advanced">Avancé</span>
93            }
94            @default {
95              <span class="badge beginner">Débutant</span>
96            }
97          }
98        </div>
99      </section>
100    </div>
101  `,
102  styles: [`
103    .dojo-training {
104      font-family: 'Segoe UI', sans-serif;
105      max-width: 900px;
106      margin: 0 auto;
107      padding: 20px;
108      background: #1a1a2e;
109      color: #eee;
110    }
111
112    header {
113      text-align: center;
114      border-bottom: 2px solid #c9a227;
115      padding-bottom: 20px;
116      margin-bottom: 20px;
117    }
118
119    h1 { color: #c9a227; }
120    h2 { color: #c9a227; margin-top: 30px; }
121    h3 { color: #888; font-size: 0.9rem; margin-bottom: 10px; }
122
123    .demo-box {
124      background: rgba(255,255,255,0.05);
125      border: 1px solid #333;
126      border-radius: 8px;
127      padding: 15px;
128      margin-bottom: 15px;
129    }
130
131    button {
132      background: #c9a227;
133      color: #1a1a2e;
134      border: none;
135      padding: 10px 20px;
136      margin: 5px;
137      border-radius: 4px;
138      cursor: pointer;
139    }
140
141    button:hover { opacity: 0.9; }
142
143    input {
144      padding: 10px;
145      border: 1px solid #c9a227;
146      border-radius: 4px;
147      background: #16213e;
148      color: #eee;
149      width: 200px;
150    }
151
152    progress {
153      width: 100%;
154      height: 20px;
155    }
156
157    .status { padding: 10px; border-radius: 4px; }
158    .status.good { background: #2d6a4f; }
159    .status.warning { background: #b45309; }
160    .status.danger { background: #991b1b; }
161
162    ul { list-style: none; padding: 0; }
163    li {
164      padding: 10px;
165      margin: 5px 0;
166      background: #16213e;
167      border-radius: 4px;
168      cursor: pointer;
169    }
170    li:hover { background: #1e3a5f; }
171    li.selected { border: 2px solid #c9a227; }
172
173    .badge {
174      padding: 5px 15px;
175      border-radius: 20px;
176      font-weight: bold;
177    }
178    .badge.master { background: #c9a227; color: #1a1a2e; }
179    .badge.advanced { background: #7c3aed; }
180    .badge.beginner { background: #4a5568; }
181  `]
182})
183export class AppComponent {
184  samurai: Samurai = {
185    id: 1,
186    name: 'Musashi',
187    clan: 'Niten Ichi-ryū',
188    level: 8,
189    health: 100
190  };
191
192  samuraiList: Samurai[] = [
193    { id: 1, name: 'Musashi', clan: 'Niten', level: 10, health: 100 },
194    { id: 2, name: 'Kojiro', clan: 'Ganryū', level: 9, health: 85 },
195    { id: 3, name: 'Jubei', clan: 'Yagyu', level: 7, health: 90 }
196  ];
197
198  selectedId = 1;
199
200  attack(): void {
201    this.samurai.health = Math.max(0, this.samurai.health - 15);
202  }
203
204  heal(): void {
205    this.samurai.health = Math.min(100, this.samurai.health + 20);
206  }
207
208  selectSamurai(id: number): void {
209    this.selectedId = id;
210  }
211
212  getRank(level: number): string {
213    if (level >= 9) return 'master';
214    if (level >= 5) return 'advanced';
215    return 'beginner';
216  }
217}

Retiens bien : les accolades montrent les données, les crochets fixent les propriétés, les parenthèses apportent les rapports d'événements, et la banane dans la boîte mène une conversation dans les deux sens.

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. 1. Quelle syntaxe est utilisée pour l'interpolation dans un template Angular ?

  2. 2. Quelle syntaxe est utilisée pour écouter les événements dans Angular ?

  3. 3. Quelle syntaxe est utilisée pour la liaison de propriété dans Angular ?

Tâches pratiques dans le jeu

  • Tri horizontal

    Ordonne les éléments sur une seule ligne pour créer une liaison bidirectionnelle entre la valeur d'un champ input et la propriété samuraiName :

  • Éditeur de code

    Complète le code : ajoute une liaison de propriété pour [disabled] et une liaison d'événement pour (click) sur le bouton d'attaque.

  • Clique dans l'ordre

    Ordonne les caractères pour construire une liaison de propriété pour l'attribut src : [src]

  • Tri horizontal

    Ordonne les éléments sur une seule ligne pour lier la méthode onAttack() au clic d'un bouton :

Articles utiles