Cours Vue.js · Module 4: Computed et watch
Watch - système de surveillance
Dans cette leçon4
Une computed répond à la question "combien ça vaut", mais une bonne partie du travail de la station ne concerne pas des valeurs : ce sont des réactions. Quand un opérateur tape le nom d'un module, il faut interroger la base de NOVA LAB. Quand la température du réacteur dépasse la norme, il faut déclencher une alarme et inscrire l'événement dans le journal. Ce genre d'effets de bord ne doit jamais aller dans une computed, car son getter doit seulement calculer et retourner un résultat tout de suite. Réagir aux changements, c'est le rôle d'un watcher, le capteur d'alarme du Centre de Contrôle.
watch() de base
La fonction watch prend deux arguments et un troisième facultatif. Le premier est la source, ce que l'on observe : un ref, un objet créé avec reactive, un getter ou un tableau de telles sources. Le deuxième est le callback, que Vue appelle après chaque changement de la source en lui passant la nouvelle et l'ancienne valeur. Un watcher est paresseux par défaut : à sa création il ne fait rien et attend le premier changement. Voici une recherche de modules dont le callback est une fonction asynchrone, et où le délai de réponse de la base est simulé par un await sur une promesse résolue par setTimeout.
1<template>
2 <div class="module-search" style="background: #0a0e27; color: #00ff88;">
3 <input v-model="searchQuery" placeholder="Rechercher un module..." style="background: #1a1e37; color: #00ff88;" />
4 <p v-if="isSearching" style="color: #00b4d8;">Recherche en cours...</p>
5 <ul>
6 <li v-for="result in searchResults" :key="result.id" style="border-bottom: 1px solid #00b4d8;">
7 {{ result.name }}
8 </li>
9 </ul>
10 </div>
11</template>
12
13<script setup>
14import { ref, watch } from 'vue'
15
16const searchQuery = ref('')
17const searchResults = ref([])
18const isSearching = ref(false)
19
20// Watch sur une seule valeur
21watch(searchQuery, async (newQuery, oldQuery) => {
22 console.log(`Recherche modifiée : "${oldQuery}" → "${newQuery}"`)
23
24 if (newQuery.length < 2) {
25 searchResults.value = []
26 return
27 }
28
29 isSearching.value = true
30
31 // Simulation d'un appel API à la base de données NOVA LAB
32 await new Promise(resolve => setTimeout(resolve, 500))
33
34 searchResults.value = [
35 { id: 1, name: `Module : ${newQuery}` }
36 ]
37
38 isSearching.value = false
39})
40</script>Tape "ox", et une ligne avec l'ancienne et la nouvelle requête apparaît dans la console, suivie une demi-seconde plus tard du résultat de la recherche. Les requêtes de moins de deux caractères vident la liste et terminent le callback par une instruction return avant que quoi que ce soit ne parte. Ce qui n'a pas changé : searchQuery reste un ref ordinaire relié par v-model, et le watcher n'y écrit rien, il se contente d'écouter. Remarque aussi le moment de la réaction. Vue n'appelle pas le callback sur la ligne même où tu modifies la valeur : il capte le changement et le traite au tick suivant, avant de rafraîchir le DOM. Deux changements dans le même tick donnent donc un seul appel, déjà avec la valeur la plus récente.
Options de watch
Le troisième argument est un objet d'options. immediate: true exécute le callback dès la création du watcher, avec une ancienne valeur égale à undefined, ce qui est pratique pour charger des données au démarrage d'un panneau. deep: true active l'observation en profondeur des objets imbriqués. flush choisit le moment de l'appel : 'pre' (par défaut, avant la mise à jour du DOM), 'post' (après la mise à jour, quand tu veux lire le DOM rafraîchi) ou 'sync' (immédiatement, à chaque changement). once: true, disponible depuis Vue 3.4, arrête le watcher après son premier appel.
1import { ref, watch } from 'vue'
2
3const count = ref(0)
4
5watch(count, (newValue) => {
6 console.log('Count:', newValue)
7}, {
8 immediate: true, // Appelle immédiatement à l'initialisation
9 deep: true, // Observation profonde des objets
10 flush: 'post', // Quand exécuter : 'pre', 'post', 'sync'
11 once: true // Une seule fois (Vue 3.4+)
12})Ce bloc est un aide-mémoire, pas un modèle à copier. count est un nombre, donc deep ne change rien ici, et combiner immediate avec once signifie que le callback s'exécute une fois, dès la création, puis que le watcher s'éteint aussitôt. En pratique, la configuration suit toujours le même ordre : import de watch depuis vue, la source, le callback, et à la fin seulement les options dont tu as vraiment besoin.
Watch sur plusieurs sources
Les systèmes de support de vie s'évaluent ensemble : l'oxygène et la température. Au lieu de deux watchers, tu passes un tableau de sources. Le callback reçoit alors un tableau de nouvelles valeurs et un tableau d'anciennes, dans le même ordre que les sources, et la déstructuration [newO2, newTemp] leur donne tout de suite un nom.
1import { ref, watch } from 'vue'
2
3const oxygenLevel = ref(95)
4const temperature = ref(22)
5
6// Tableau de sources - surveillance de plusieurs paramètres
7watch([oxygenLevel, temperature], ([newO2, newTemp], [oldO2, oldTemp]) => {
8 console.log(`Support de vie modifié :
9 O2: ${oldO2}% → ${newO2}%
10 Temp: ${oldTemp}°C → ${newTemp}°C`)
11})Le callback s'exécute quand l'une quelconque des sources change. Si seul l'oxygène a changé, newTemp et oldTemp sont égales. Le watcher ne te dit pas quelle source a bougé, donc compare toi-même les paires si tu as besoin de le savoir.
Watch sur un getter
Un objet reactive demande de la prudence. Écrire watch(reactor.temperature, callback) ne fonctionnera pas, car tu passes ainsi le nombre 2500 et non une source réactive, et Vue signale une source de watch invalide. Tu observes une propriété d'objet à travers un getter, la fonction () => reactor.temperature, que Vue appelle pour lire la valeur actuelle.
1import { reactive, watch } from 'vue'
2
3const reactor = reactive({
4 name: 'Plasma Core Alpha',
5 temperature: 2500
6})
7
8// Watch sur un getter (fonction)
9watch(
10 () => reactor.temperature,
11 (newTemp) => {
12 console.log('Nouvelle température du réacteur :', newTemp)
13 if (newTemp > 5000) {
14 console.warn('CRITIQUE : la température dépasse les limites de sécurité !')
15 }
16 }
17)
18
19// Watch sur un objet profond
20watch(
21 () => reactor,
22 (newReactor) => {
23 console.log('Configuration du réacteur modifiée :', newReactor)
24 },
25 { deep: true }
26)Le premier watcher ne réagit qu'à la température : renommer le réacteur ne le réveillera pas. Le second observe un getter qui retourne l'objet entier, c'est pourquoi il a besoin de { deep: true }. Sans cette option, il ne réagirait au changement d'aucun champ, car un getter sans deep ne réveille le watcher que lorsqu'il retourne une valeur différente de la précédente, et ici il retourne toujours le même objet. Un objet reactive passé directement, sans getter, crée automatiquement un watcher profond. Avec un ref qui contient un objet, c'est l'inverse : la modification d'un champ imbriqué comme missionData.value.systems.oxygen passe inaperçue sans deep: true. Après une telle modification sur place, la nouvelle et l'ancienne valeur désignent le même objet, donc les comparer ne t'apprend rien.
Garde le coût en tête : à chaque exécution, deep parcourt tous les champs imbriqués, ce qui peut coûter cher sur de grandes structures. Depuis Vue 3.5, tu peux limiter la profondeur avec un nombre, par exemple deep: 2. Je te recommande quand même de commencer par un getter qui vise le champ précis : un capteur branché sur un seul câble coûte moins cher qu'un scanner de tout le module. Dans la prochaine leçon, tu découvriras watchEffect, qui trouve ses sources tout seul.
Souviens-toi : watch est un capteur d'alarme - tu le pointes vers une source, et à chaque changement il te donne la nouvelle et l'ancienne lecture.
Code de cette leçon : App.vue
1<script setup>
2import { ref, watch } from 'vue'
3
4// NOVA LAB - Surveillance des systèmes avec watch()
5const oxygenLevel = ref(95)
6const powerLevel = ref(2800)
7const waterLevel = ref(85)
8const logs = ref([])
9
10// watch() - réagit aux changements d'une source précise
11watch(oxygenLevel, (newValue, oldValue) => {
12 const timestamp = new Date().toLocaleTimeString()
13 logs.value.unshift({
14 time: timestamp,
15 type: 'oxygen',
16 message: `Oxygène : ${oldValue}% → ${newValue}%`,
17 color: newValue < 80 ? '#ff0044' : '#00ff88'
18 })
19
20 if (newValue < 80) {
21 logs.value.unshift({
22 time: timestamp,
23 type: 'warning',
24 message: 'ATTENTION : niveau d\'oxygène bas détecté !',
25 color: '#ffaa00'
26 })
27 }
28})
29
30watch(powerLevel, (newValue, oldValue) => {
31 const timestamp = new Date().toLocaleTimeString()
32 logs.value.unshift({
33 time: timestamp,
34 type: 'power',
35 message: `Énergie : ${oldValue}W → ${newValue}W`,
36 color: newValue < 2000 ? '#ff0044' : '#00b4d8'
37 })
38
39 if (newValue < 2000) {
40 logs.value.unshift({
41 time: timestamp,
42 type: 'critical',
43 message: 'CRITIQUE : énergie sous le seuil minimum !',
44 color: '#ff0044'
45 })
46 }
47})
48
49// Watch sur plusieurs sources
50watch([oxygenLevel, powerLevel, waterLevel], ([o2, power, water]) => {
51 const allOptimal = o2 > 90 && power > 2500 && water > 80
52 if (allOptimal) {
53 const timestamp = new Date().toLocaleTimeString()
54 logs.value.unshift({
55 time: timestamp,
56 type: 'success',
57 message: 'Tous les systèmes optimaux !',
58 color: '#00ff88'
59 })
60 }
61})
62
63function adjustOxygen(delta) {
64 oxygenLevel.value = Math.max(0, Math.min(100, oxygenLevel.value + delta))
65}
66
67function adjustPower(delta) {
68 powerLevel.value = Math.max(0, powerLevel.value + delta)
69}
70
71function adjustWater(delta) {
72 waterLevel.value = Math.max(0, Math.min(100, waterLevel.value + delta))
73}
74
75function clearLogs() {
76 logs.value = []
77}
78
79function simulateEmergency() {
80 oxygenLevel.value = 65
81 powerLevel.value = 1500
82 waterLevel.value = 45
83}
84
85function restoreNominal() {
86 oxygenLevel.value = 95
87 powerLevel.value = 2800
88 waterLevel.value = 85
89}
90</script>
91
92<template>
93 <div class="monitor-system">
94 <div class="header">
95 <h1>NOVA LAB System Monitor</h1>
96 <p class="subtitle">API watch() - surveillance réactive</p>
97 </div>
98
99 <div class="controls-panel">
100 <div class="system-control">
101 <h3>Système d'oxygène</h3>
102 <div class="level-bar">
103 <div
104 class="level-fill oxygen"
105 :style="{ width: oxygenLevel + '%' }"
106 ></div>
107 <span class="level-text">{{ oxygenLevel }}%</span>
108 </div>
109 <div class="buttons">
110 <button @click="adjustOxygen(-10)">-10%</button>
111 <button @click="adjustOxygen(-5)">-5%</button>
112 <button @click="adjustOxygen(5)">+5%</button>
113 <button @click="adjustOxygen(10)">+10%</button>
114 </div>
115 </div>
116
117 <div class="system-control">
118 <h3>Système d'énergie</h3>
119 <div class="level-bar">
120 <div
121 class="level-fill power"
122 :style="{ width: (powerLevel / 30) + '%' }"
123 ></div>
124 <span class="level-text">{{ powerLevel }}W</span>
125 </div>
126 <div class="buttons">
127 <button @click="adjustPower(-200)">-200W</button>
128 <button @click="adjustPower(-100)">-100W</button>
129 <button @click="adjustPower(100)">+100W</button>
130 <button @click="adjustPower(200)">+200W</button>
131 </div>
132 </div>
133
134 <div class="system-control">
135 <h3>Réserves d'eau</h3>
136 <div class="level-bar">
137 <div
138 class="level-fill water"
139 :style="{ width: waterLevel + '%' }"
140 ></div>
141 <span class="level-text">{{ waterLevel }}%</span>
142 </div>
143 <div class="buttons">
144 <button @click="adjustWater(-10)">-10%</button>
145 <button @click="adjustWater(-5)">-5%</button>
146 <button @click="adjustWater(5)">+5%</button>
147 <button @click="adjustWater(10)">+10%</button>
148 </div>
149 </div>
150 </div>
151
152 <div class="scenario-buttons">
153 <button @click="simulateEmergency" class="danger">Simuler une urgence</button>
154 <button @click="restoreNominal" class="success">Rétablir le nominal</button>
155 <button @click="clearLogs" class="neutral">Effacer les logs</button>
156 </div>
157
158 <div class="log-panel">
159 <h3>Journal des événements système</h3>
160 <div class="log-entries">
161 <div
162 v-for="(log, index) in logs.slice(0, 15)"
163 :key="index"
164 class="log-entry"
165 :style="{ borderLeftColor: log.color, color: log.color }"
166 >
167 <span class="log-time">[{{ log.time }}]</span>
168 <span class="log-message">{{ log.message }}</span>
169 </div>
170 <div v-if="logs.length === 0" class="no-logs">
171 Aucun événement enregistré pour l'instant. Ajuste les paramètres système pour voir watch() en action !
172 </div>
173 </div>
174 </div>
175
176 <div class="info-box">
177 <strong>API watch() :</strong> surveille des sources réactives précises et exécute des callbacks lorsqu'elles changent.
178 Parfait pour la journalisation, les effets de bord et les réponses conditionnelles !
179 </div>
180 </div>
181</template>
182
183<style>
184* {
185 margin: 0;
186 padding: 0;
187 box-sizing: border-box;
188}
189
190body {
191 font-family: 'Courier New', monospace;
192 background: #0a0e27;
193 color: #00ff88;
194 padding: 20px;
195}
196
197.monitor-system {
198 max-width: 1100px;
199 margin: 0 auto;
200}
201
202.header {
203 text-align: center;
204 margin-bottom: 30px;
205 padding: 20px;
206 background: linear-gradient(135deg, #1a1f4c 0%, #0a0e27 100%);
207 border: 2px solid #00ff88;
208 border-radius: 10px;
209}
210
211.header h1 {
212 font-size: 2rem;
213 margin-bottom: 10px;
214 text-shadow: 0 0 20px #00ff88;
215}
216
217.subtitle {
218 color: #00b4d8;
219 font-size: 1.1rem;
220}
221
222.controls-panel {
223 display: grid;
224 gap: 20px;
225 margin-bottom: 20px;
226}
227
228.system-control {
229 background: rgba(0, 180, 216, 0.05);
230 border: 2px solid #00b4d8;
231 border-radius: 10px;
232 padding: 20px;
233}
234
235.system-control h3 {
236 color: #00b4d8;
237 margin-bottom: 15px;
238}
239
240.level-bar {
241 position: relative;
242 height: 40px;
243 background: rgba(0, 0, 0, 0.5);
244 border: 1px solid #00ff88;
245 border-radius: 5px;
246 margin-bottom: 15px;
247 overflow: hidden;
248}
249
250.level-fill {
251 height: 100%;
252 transition: width 0.3s ease;
253}
254
255.level-fill.oxygen {
256 background: linear-gradient(90deg, #00ff88, #00cc66);
257}
258
259.level-fill.power {
260 background: linear-gradient(90deg, #00b4d8, #0088ff);
261}
262
263.level-fill.water {
264 background: linear-gradient(90deg, #0088ff, #0066cc);
265}
266
267.level-text {
268 position: absolute;
269 top: 50%;
270 left: 50%;
271 transform: translate(-50%, -50%);
272 font-weight: bold;
273 font-size: 1.2rem;
274 color: white;
275 text-shadow: 0 0 5px black;
276}
277
278.buttons {
279 display: flex;
280 gap: 10px;
281 justify-content: center;
282}
283
284button {
285 background: linear-gradient(135deg, #00b4d8 0%, #0077aa 100%);
286 color: white;
287 border: none;
288 padding: 10px 20px;
289 border-radius: 5px;
290 cursor: pointer;
291 font-family: 'Courier New', monospace;
292 font-weight: bold;
293 transition: all 0.3s ease;
294}
295
296button:hover {
297 transform: translateY(-2px);
298 box-shadow: 0 5px 15px rgba(0, 180, 216, 0.3);
299}
300
301.scenario-buttons {
302 display: flex;
303 gap: 15px;
304 justify-content: center;
305 margin-bottom: 20px;
306}
307
308.scenario-buttons button {
309 padding: 15px 30px;
310 font-size: 1rem;
311}
312
313.danger {
314 background: linear-gradient(135deg, #ff0044 0%, #cc0033 100%) !important;
315}
316
317.success {
318 background: linear-gradient(135deg, #00ff88 0%, #00cc66 100%) !important;
319}
320
321.neutral {
322 background: linear-gradient(135deg, #666 0%, #444 100%) !important;
323}
324
325.log-panel {
326 background: rgba(0, 0, 0, 0.5);
327 border: 2px solid #00ff88;
328 border-radius: 10px;
329 padding: 20px;
330 margin-bottom: 20px;
331}
332
333.log-panel h3 {
334 color: #00ff88;
335 margin-bottom: 15px;
336}
337
338.log-entries {
339 max-height: 300px;
340 overflow-y: auto;
341}
342
343.log-entry {
344 padding: 10px;
345 margin-bottom: 8px;
346 background: rgba(0, 255, 136, 0.05);
347 border-left: 4px solid;
348 border-radius: 4px;
349 font-size: 0.9rem;
350 animation: slideIn 0.3s ease;
351}
352
353@keyframes slideIn {
354 from {
355 opacity: 0;
356 transform: translateX(-20px);
357 }
358 to {
359 opacity: 1;
360 transform: translateX(0);
361 }
362}
363
364.log-time {
365 opacity: 0.7;
366 margin-right: 10px;
367}
368
369.no-logs {
370 text-align: center;
371 padding: 30px;
372 color: #666;
373 font-style: italic;
374}
375
376.info-box {
377 background: rgba(0, 255, 136, 0.1);
378 border: 2px solid #00ff88;
379 border-radius: 10px;
380 padding: 15px;
381 text-align: center;
382 color: #00ff88;
383 line-height: 1.6;
384}
385</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. À quoi sert watch() dans Vue.js ?
2. Quels paramètres reçoit le callback dans watch() ?
Voici 2 des 4 questions de cette leçon. Réponds aux autres dans le jeu.
Tâches pratiques dans le jeu
- Clique dans l'ordre
Ordonne la syntaxe correcte de watch :
- Éditeur de code
Complète le watch qui observe searchQuery et journalise les changements avec la nouvelle et l'ancienne valeur.
- Tri vertical
Ordonne les étapes de configuration d'un watch dans le bon ordre :
- Éditeur de code
Complète le watch avec l'option immediate: true pour récupérer les données immédiatement après la création du watcher.
- Tri vertical
Ordonne les étapes du fonctionnement d'un watch, du changement de source jusqu'au callback :
- Tri horizontal
Ordonne watch avec l'option deep dans le bon ordre :
- Éditeur de code
Complète le watch avec { deep: true } pour observer les changements dans un objet réactif imbriqué.