Cours Vue.js · Module 5: Événements et formulaires

Les modificateurs de v-model - calibrer le signal des panneaux

10 min de lecture
Dans cette leçon5

Dans la leçon précédente, tu as relié le panneau de mission à des variables : le nom de l'équipage, les notes, les cases à cocher des systèmes, la liste des modules. Le formulaire fonctionne, mais le signal qui en sort est brut. L'officier de quart du Centre de Contrôle signale trois pannes. D'abord, le journal de télémétrie enregistre une nouvelle valeur à chaque frappe de touche, si bien qu'un seul mot y laisse une douzaine d'entrées. Ensuite, le champ des crédits renvoie du texte et non un nombre, donc chaque addition colle les chiffres au lieu de les additionner. Enfin, l'opérateur colle le nom d'un module depuis le presse-papiers en emportant l'espace de fin, et la comparaison avec le catalogue de la station ne trouve plus rien.

Aucune de ces pannes ne se répare à la main. Exactement comme tu ajoutais .prevent ou .stop aux événements, tu peux ajouter un modificateur à v-model : un point et un nom, juste derrière le nom de la directive. Le modificateur ne change pas ce à quoi le champ est lié - il change la façon dont la valeur arrive jusqu'à la variable. Vue en propose trois : .lazy, .number et .trim. Chacun répare exactement une des trois pannes de l'officier de quart.

Quand la valeur atterrit dans la variable

Commençons par la première panne, car elle porte sur le moment, pas sur le contenu. Un champ texte relié par v-model écoute l'événement navigateur input, que le navigateur émet après chaque caractère saisi. Pour un champ avec aperçu en direct, c'est le comportement idéal : l'opérateur tape, le panneau montre aussitôt le résultat. Pour un journal de télémétrie ou un recalcul de trajectoire coûteux, c'est du gaspillage : ce qui nous intéresse, c'est une valeur finie, pas une douzaine d'états intermédiaires.

Le navigateur dispose d'un événement fait pour cette occasion : change. Sur un champ texte, il ne le déclenche qu'au moment où la valeur modifiée est validée - c'est-à-dire quand tu quittes le champ (événement blur) ou quand tu appuies sur Entrée. Le modificateur .lazy ne fait qu'une seule chose : il fait passer l'écoute de v-model de l'événement input à l'événement change. Voici deux canaux de lecture construits à l'identique, qui ne diffèrent que par ce modificateur.

1<template>
2  <label>Direct (input)</label>
3  <input v-model="liveSignal" />
4  <p>Lecture : {{ liveSignal }}</p>
5
6  <label>Lazy (change)</label>
7  <input v-model.lazy="committedSignal" />
8  <p>Lecture : {{ committedSignal }}</p>
9</template>
10
11<script setup>
12import { ref } from 'vue'
13
14const liveSignal = ref('')
15const committedSignal = ref('')
16</script>

Tape quelque chose dans le premier champ : le paragraphe en dessous change lettre après lettre. Tape la même chose dans le second : le paragraphe reste vide tant que tu n'as pas cliqué ailleurs ou appuyé sur Entrée. Et voici le plus important, ce qui n'a pas changé. La liaison reste bidirectionnelle, la variable committedSignal reste un texte ordinaire, et le template ne diffère que de cinq caractères. Seul le moment de la mise à jour s'est déplacé.

Autant écarter tout de suite trois malentendus, car .lazy se voit souvent prêter des pouvoirs qu'il n'a pas. Ce n'est pas un minuteur : rien ne se déclenche toutes les 500 millisecondes. Un délai mesuré à l'horloge s'appelle un debounce et, en Vue, tu l'écris toi-même avec un watcher et setTimeout, comme dans le module consacré aux systèmes de surveillance. Ce n'est pas non plus une attente de l'envoi du formulaire : la valeur atterrit dans la variable dès que tu quittes le champ, bien avant le clic sur le bouton. Et enfin, la mise à jour reste automatique, tu n'as rien à recopier à la main.

Un nombre qui se fait passer pour du texte

La deuxième panne est plus sournoise, car le panneau a l'air correct jusqu'à la première opération arithmétique. Un champ de formulaire du navigateur renvoie toujours du texte, y compris quand tu n'y saisis que des chiffres. Si tu affectes cette valeur à une variable et que tu y ajoutes un nombre, JavaScript ne calcule pas une somme : il colle deux chaînes bout à bout. Observe le phénomène sur le champ des crédits de mission, un champ texte ordinaire, sans aucun modificateur pour l'instant.

1<template>
2  <label>Credits</label>
3  <input v-model="budget" />
4
5  <p>Type : {{ typeof budget }}</p>
6  <p>budget + 10 = {{ budget + 10 }}</p>
7</template>
8
9<script setup>
10import { ref } from 'vue'
11
12const budget = ref('')
13</script>

Saisis 1500. Le premier paragraphe affiche string, et le second, au lieu de 1510, une chaîne composée de la valeur saisie avec un 10 collé derrière. Ce n'est un défaut ni de Vue ni du navigateur, seulement la conséquence naturelle du fait qu'un champ de formulaire stocke des caractères. Tant que la valeur ne sert qu'à être affichée, personne ne remarque rien. Les ennuis commencent quand on additionne des budgets, qu'on compare des plafonds et qu'on trie les modules par coût.

Le remède s'appelle .number. Vue fait alors passer la valeur saisie par une conversion en nombre qui se comporte comme parseFloat : le nombre est extrait du texte et, si la conversion échoue, c'est l'original intact qui atterrit dans la variable. La distinction est capitale : .number convertit, mais ne valide pas. Il ne rejette aucune saisie, n'allume aucun voyant rouge et ne bloque pas le formulaire ; si le contenu ne peut pas devenir un nombre, il laisse simplement le texte tel quel. Le contrôle de validité est un travail à part, que tu feras dans la leçon suivante.

1<template>
2  <label>Credits</label>
3  <input v-model.number="budget" type="number" />
4
5  <p>Type : {{ typeof budget }}</p>
6  <p>budget + 10 = {{ budget + 10 }}</p>
7</template>
8
9<script setup>
10import { ref } from 'vue'
11
12const budget = ref(0)
13</script>

Le même template, un modificateur ajouté, et le premier paragraphe affiche maintenant number, le second une vraie somme. Remarque bien ce que .number ne fait pas. Il ne veille pas à ce qu'on ne puisse taper que des chiffres : ce rôle revient à l'attribut type="number", qui appartient au navigateur et décide seul de ce qu'il est permis de saisir dans le champ. Il ne met pas non plus le nombre en forme avec des séparateurs de milliers ; cette écriture, faite pour le confort du lecteur, se prépare à part, au moment de l'affichage, avec la méthode toLocaleString. Dans le champ lui-même, la valeur reste un nombre brut.

Puisque l'attribut type="number" vient d'apparaître, une réserve honnête s'impose. Vue considère un tel champ comme numérique par nature et tente la conversion même sans modificateur, si bien que dans cet exemple précis .number ne change rien. Je te recommande malgré tout de l'écrire toujours et explicitement. D'abord, l'intention devient visible dans le template et ne dépend plus du type que quelqu'un remplacera peut-être un jour. Ensuite, la même écriture fonctionne aussi sur un champ texte ordinaire. La forme canonique, celle qu'il faut avoir dans les doigts, est exactement celle du bloc ci-dessus : d'abord <input, puis v-model.number= avec le nom de la variable entre guillemets, et enfin type="number" />.

Les espaces que personne n'a tapés

La troisième panne vient d'un réflexe humain. L'opérateur copie le nom d'un module depuis un rapport et emporte avec lui l'espace de fin. À l'écran, rien d'inquiétant, mais dans la variable se cache un caractère qui casse tout ce qui compare des textes : la recherche dans le catalogue de la station ne retrouve pas le module, et le test naïf qui demande si le champ est vide laisse passer une saisie composée uniquement d'espaces. Le modificateur .trim supprime les espaces au début et à la fin de la valeur, avant que celle-ci n'arrive dans la variable.

1<template>
2  <label>Sans trim</label>
3  <input v-model="rawName" />
4  <p>chars : {{ rawName.length }}</p>
5
6  <label>Avec trim</label>
7  <input v-model.trim="missionName" />
8  <p>chars : {{ missionName.length }}</p>
9</template>
10
11<script setup>
12import { ref } from 'vue'
13
14const rawName = ref('')
15const missionName = ref('')
16</script>

Saisis dans les deux champs un nom précédé et suivi d'un espace. Le compteur de caractères sous le premier champ compte ces espaces, celui sous le second ne les compte pas. Voici maintenant trois limites qu'on oublie facilement. .trim ne tronque pas le texte à une longueur donnée : il n'existe aucune limite de caractères, seuls les bords sont rognés. Il ne supprime pas non plus tous les espaces : le nom Oxygène Alpha garde l'espace du milieu, car le modificateur ne touche jamais à l'intérieur de la chaîne. Et il ne change pas la casse : passer en minuscules, c'est toLowerCase, un outil complètement différent. Tout aussi important, le modificateur travaille sur la valeur qui part vers la variable, pas sur ce que tu tapes physiquement dans le champ ; pendant la saisie, l'espace reste visible à l'écran, simplement la variable ne le reçoit pas.

Combiner les modificateurs

Le panneau d'enregistrement de mission a besoin des trois corrections à la fois, et tu n'as pas à choisir. Les modificateurs se chaînent : tu écris les noms les uns derrière les autres, chacun précédé d'un point, sans espace et sans virgule. L'écriture v-model.lazy.trim="text" est parfaitement valide, alors qu'une virgule entre les noms formerait pour Vue un seul nom de modificateur inconnu, et ne ferait tout simplement rien.

Vue n'impose aucun ordre, donc v-model.trim.lazy signifie exactement la même chose que v-model.lazy.trim. Puisque le choix t'appartient, je te conseille de t'en tenir à une convention stable : d'abord le modificateur qui décrit ce qu'est la valeur (.number ou .trim), ensuite celui qui décrit quand elle arrive dans la variable (.lazy). D'où v-model.number.lazy, qui se lit comme une phrase : un nombre, validé à la sortie du champ.

1<template>
2  <form @submit.prevent="registerMission">
3    <label>Nom de la mission</label>
4    <input v-model.trim.lazy="missionName" />
5
6    <label>Credits</label>
7    <input v-model.number.lazy="budget" type="number" />
8
9    <button type="submit">Enregistrer</button>
10  </form>
11
12  <p>Panneau : {{ missionName }} / {{ budget }}</p>
13</template>
14
15<script setup>
16import { ref } from 'vue'
17
18const missionName = ref('')
19const budget = ref(0)
20
21function registerMission() {
22  console.log('Mission enregistree', missionName.value, budget.value)
23}
24</script>

Remplis les deux champs et passe de l'un à l'autre avec la touche Tab, sans toucher au bouton. Le paragraphe sous le formulaire affiche déjà le nom nettoyé et le budget numérique : la preuve que .lazy attend la sortie du champ, et non l'envoi du formulaire. Un clic sur le bouton retire d'abord le focus du champ, donc la fonction registerMission reçoit toujours des valeurs à jour. Pour le reste, rien de nouveau dans ce bloc : @submit.prevent vient de la leçon sur les modificateurs d'événements, tandis que ref et v-model t'accompagnent depuis le début du module. Seuls les points et leurs modificateurs se sont ajoutés.

Quand un modificateur gêne

Pour finir, une mise en garde, car les modificateurs s'ajoutent parfois par réflexe. Ne mets pas .lazy sur un champ qui filtre la liste des modules en direct : l'opérateur perdrait l'aperçu des résultats pendant la saisie et jugerait le panneau cassé. Méfie-toi de .number sur des désignations qui ressemblent seulement à des nombres : un numéro de secteur écrit 007 devient 7 après conversion et ne correspond plus au catalogue de la station. Avec .trim sur des noms, des adresses et des identifiants, les ennuis sont quasi inexistants, et tu peux l'appliquer par défaut sans hésiter.

Retiens ceci : un modificateur de v-model est la molette de calibrage du panneau - le signal passe par le même câble, mais il arrive à l'ordinateur de mission déjà propre, dans le bon type et au bon moment.

Code de cette leçon : App.vue
1<script setup>
2import { ref } from 'vue'
3
4const crewMember = ref({
5  name: '',
6  role: 'pilot',
7  certifications: [],
8  experience: 'beginner',
9  availability: false
10})
11
12const roles = [
13  { value: 'pilot', label: 'Pilote' },
14  { value: 'engineer', label: 'Ingénieur' },
15  { value: 'scientist', label: 'Scientifique' },
16  { value: 'medic', label: 'Médecin' },
17  { value: 'commander', label: 'Commandant' }
18]
19
20const availableCertifications = [
21  { id: 'eva', name: 'EVA (Sortie dans l\'espace)' },
22  { id: 'repair', name: 'Réparation du vaisseau' },
23  { id: 'bio', name: 'Recherche biologique' },
24  { id: 'geo', name: 'Géologie martienne' },
25  { id: 'comm', name: 'Systèmes de communication' }
26]
27
28const experienceLevels = [
29  { value: 'beginner', label: 'Débutant (0-2 ans)' },
30  { value: 'intermediate', label: 'Intermédiaire (3-5 ans)' },
31  { value: 'advanced', label: 'Avancé (6-10 ans)' },
32  { value: 'expert', label: 'Expert (10+ ans)' }
33]
34</script>
35
36<template>
37  <div class="crew-registration">
38    <div class="registration-header">
39      <h2>Enregistrement d'un Membre d'Équipage</h2>
40      <p class="subtitle">Formulaires - Différents types d'input</p>
41    </div>
42
43    <div class="form-section">
44      <!-- Champ texte -->
45      <div class="form-field">
46        <label for="name">Nom complet :</label>
47        <input
48          id="name"
49          v-model="crewMember.name"
50          type="text"
51          placeholder="ex. Jean Dupont"
52          class="text-input"
53        />
54      </div>
55
56      <!-- Boutons radio -->
57      <div class="form-field">
58        <label>Rôle dans la mission :</label>
59        <div class="radio-group">
60          <label
61            v-for="role in roles"
62            :key="role.value"
63            class="radio-label"
64          >
65            <input
66              v-model="crewMember.role"
67              type="radio"
68              :value="role.value"
69              class="radio-input"
70            />
71            <span class="radio-text">{{ role.label }}</span>
72          </label>
73        </div>
74      </div>
75
76      <!-- Cases à cocher -->
77      <div class="form-field">
78        <label>Certificats (sélectionne tous ceux que tu as) :</label>
79        <div class="checkbox-group">
80          <label
81            v-for="cert in availableCertifications"
82            :key="cert.id"
83            class="checkbox-label"
84          >
85            <input
86              v-model="crewMember.certifications"
87              type="checkbox"
88              :value="cert.id"
89              class="checkbox-input"
90            />
91            <span class="checkbox-text">{{ cert.name }}</span>
92          </label>
93        </div>
94      </div>
95
96      <!-- Menu déroulant -->
97      <div class="form-field">
98        <label for="experience">Niveau d'expérience :</label>
99        <select
100          id="experience"
101          v-model="crewMember.experience"
102          class="select-input"
103        >
104          <option
105            v-for="level in experienceLevels"
106            :key="level.value"
107            :value="level.value"
108          >
109            {{ level.label }}
110          </option>
111        </select>
112      </div>
113
114      <!-- Case à cocher unique -->
115      <div class="form-field">
116        <label class="checkbox-label single">
117          <input
118            v-model="crewMember.availability"
119            type="checkbox"
120            class="checkbox-input"
121          />
122          <span class="checkbox-text">
123            Prêt(e) pour un départ immédiat
124          </span>
125        </label>
126      </div>
127    </div>
128
129    <div class="data-display">
130      <h3>Données du Membre d'Équipage</h3>
131      <div class="data-card">
132        <div class="data-row">
133          <span class="data-label">Nom :</span>
134          <span class="data-value">{{ crewMember.name || '(non renseigné)' }}</span>
135        </div>
136        <div class="data-row">
137          <span class="data-label">Rôle :</span>
138          <span class="data-value">{{ roles.find(r => r.value === crewMember.role)?.label }}</span>
139        </div>
140        <div class="data-row">
141          <span class="data-label">Certificats :</span>
142          <span class="data-value">
143            {{ crewMember.certifications.length > 0
144              ? crewMember.certifications.join(', ').toUpperCase()
145              : '(aucun)' }}
146          </span>
147        </div>
148        <div class="data-row">
149          <span class="data-label">Expérience :</span>
150          <span class="data-value">
151            {{ experienceLevels.find(l => l.value === crewMember.experience)?.label }}
152          </span>
153        </div>
154        <div class="data-row">
155          <span class="data-label">Disponibilité :</span>
156          <span class="data-value">
157            {{ crewMember.availability ? 'Prêt' : 'Indisponible' }}
158          </span>
159        </div>
160      </div>
161    </div>
162
163    <div class="binding-info">
164      <h3>Liaison de données pour différents inputs :</h3>
165      <ul>
166        <li><code>&lt;input type="text"&gt;</code> - String</li>
167        <li><code>&lt;input type="radio"&gt;</code> - Valeur du radio sélectionné</li>
168        <li><code>&lt;input type="checkbox"&gt;</code> - Boolean (unique) ou Array (plusieurs)</li>
169        <li><code>&lt;select&gt;</code> - Valeur de l'option sélectionnée</li>
170        <li><code>&lt;textarea&gt;</code> - String</li>
171      </ul>
172    </div>
173  </div>
174</template>
175
176<style scoped>
177.crew-registration {
178  background: linear-gradient(135deg, #0a0e27 0%, #1a1e3f 100%);
179  color: #00ff88;
180  padding: 30px;
181  border-radius: 15px;
182  border: 2px solid #00b4d8;
183  font-family: 'Courier New', monospace;
184  max-width: 900px;
185  margin: 0 auto;
186}
187
188.registration-header {
189  text-align: center;
190  margin-bottom: 30px;
191  padding-bottom: 20px;
192  border-bottom: 2px solid #00b4d8;
193}
194
195.registration-header h2 {
196  color: #00ff88;
197  margin: 0 0 10px 0;
198  font-size: 28px;
199  text-shadow: 0 0 10px #00ff88;
200}
201
202.subtitle {
203  color: #00b4d8;
204  margin: 0;
205  font-size: 14px;
206}
207
208.form-section {
209  margin-bottom: 30px;
210}
211
212.form-field {
213  margin-bottom: 25px;
214}
215
216.form-field > label {
217  display: block;
218  color: #00b4d8;
219  margin-bottom: 12px;
220  font-size: 14px;
221  font-weight: bold;
222}
223
224.text-input,
225.select-input {
226  width: 100%;
227  background: rgba(0, 180, 216, 0.1);
228  border: 2px solid #00b4d8;
229  border-radius: 8px;
230  padding: 12px 15px;
231  color: #00ff88;
232  font-size: 16px;
233  font-family: 'Courier New', monospace;
234  box-sizing: border-box;
235}
236
237.text-input:focus,
238.select-input:focus {
239  outline: none;
240  border-color: #00ff88;
241  box-shadow: 0 0 15px rgba(0, 255, 136, 0.3);
242}
243
244.radio-group,
245.checkbox-group {
246  display: flex;
247  flex-direction: column;
248  gap: 12px;
249}
250
251.radio-label,
252.checkbox-label {
253  display: flex;
254  align-items: center;
255  gap: 10px;
256  padding: 12px 15px;
257  background: rgba(0, 180, 216, 0.05);
258  border: 1px solid #00b4d8;
259  border-radius: 8px;
260  cursor: pointer;
261  transition: all 0.3s ease;
262}
263
264.radio-label:hover,
265.checkbox-label:hover {
266  background: rgba(0, 180, 216, 0.15);
267  border-color: #00ff88;
268}
269
270.radio-input,
271.checkbox-input {
272  width: 18px;
273  height: 18px;
274  cursor: pointer;
275  accent-color: #00ff88;
276}
277
278.radio-text,
279.checkbox-text {
280  color: #00ff88;
281  font-size: 14px;
282}
283
284.checkbox-label.single {
285  background: rgba(0, 255, 136, 0.1);
286  border-color: #00ff88;
287}
288
289.data-display {
290  margin-bottom: 30px;
291}
292
293.data-display h3 {
294  color: #00ff88;
295  margin-bottom: 15px;
296}
297
298.data-card {
299  background: rgba(0, 0, 0, 0.3);
300  border: 1px solid #00b4d8;
301  border-radius: 8px;
302  padding: 20px;
303}
304
305.data-row {
306  display: flex;
307  justify-content: space-between;
308  padding: 10px 0;
309  border-bottom: 1px solid rgba(0, 180, 216, 0.2);
310}
311
312.data-row:last-child {
313  border-bottom: none;
314}
315
316.data-label {
317  color: #00b4d8;
318  font-weight: bold;
319}
320
321.data-value {
322  color: #00ff88;
323  text-align: right;
324}
325
326.binding-info {
327  background: rgba(0, 255, 136, 0.05);
328  padding: 20px;
329  border-radius: 8px;
330  border: 1px solid #00ff88;
331}
332
333.binding-info h3 {
334  color: #00ff88;
335  margin-top: 0;
336}
337
338.binding-info ul {
339  margin: 0;
340  padding-left: 20px;
341}
342
343.binding-info li {
344  color: #00b4d8;
345  margin: 8px 0;
346}
347
348.binding-info code {
349  background: rgba(0, 255, 136, 0.2);
350  padding: 2px 6px;
351  border-radius: 4px;
352  color: #00ff88;
353  font-size: 12px;
354}
355</style>

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. Quand v-model.lazy met-il à jour la variable ?

  2. 2. Que fait le modificateur .number ?

Voici 2 des 4 questions de cette leçon. Réponds aux autres dans le jeu.

Tâches pratiques dans le jeu

  • Éditeur de code

    Crée un input avec .number pour le budget et .trim pour le nom de la mission.

  • Clique dans l'ordre

    Clique dans le bon ordre pour construire v-model.number.lazy :

  • Tri horizontal

    Ordonne la syntaxe correcte pour un input avec v-model.number :