Cours Vue.js · Module 5: Événements et formulaires
Les modificateurs de v-model - calibrer le signal des panneaux
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><input type="text"></code> - String</li>
167 <li><code><input type="radio"></code> - Valeur du radio sélectionné</li>
168 <li><code><input type="checkbox"></code> - Boolean (unique) ou Array (plusieurs)</li>
169 <li><code><select></code> - Valeur de l'option sélectionnée</li>
170 <li><code><textarea></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. Quand v-model.lazy met-il à jour la variable ?
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 :