Cours Vue.js · Module 4: Computed et watch

Watch - système de surveillance

7 min de lecture
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. 1. À quoi sert watch() dans Vue.js ?

  2. 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é.