Cours Vue.js · Module 4: Computed et watch

watchEffect - suivi automatique

5 min de lecture
Dans cette leçon4

L'ingénieur de quart a ajouté une troisième valeur, le niveau de batterie, au message d'un watcher, mais il a oublié de l'ajouter au tableau des sources. Pendant une semaine, le journal a affiché des états de batterie périmés, car le watcher ne se réveillait qu'au changement des deux premiers paramètres. Avec watch, la liste des sources et le corps du callback vivent séparément, et ils se désynchronisent facilement. watchEffect supprime ce problème : tu écris seulement l'effet, et Vue détermine lui-même quelles données il utilise.

Les bases de watchEffect

watchEffect prend une seule fonction, qu'on appelle l'effet. Vue l'exécute tout de suite, au moment de la création du watcher, et pendant cette exécution il note chaque valeur réactive que l'effet a lue. Ces valeurs deviennent des dépendances, et le changement de n'importe laquelle d'entre elles relance l'effet.

1<script setup>
2import { ref, watchEffect } from 'vue'
3
4const count = ref(0)
5const message = ref('Hello')
6
7// Suit automatiquement count et message
8watchEffect(() => {
9  console.log(`Count: ${count.value}, Message: ${message.value}`)
10})
11
12// Sera appelée :
13// 1. Immédiatement à l'initialisation
14// 2. Quand count change
15// 3. Quand message change
16</script>

La console affiche la première entrée avant que tu aies changé quoi que ce soit, puis une nouvelle après chaque changement de count ou de message. Les dépendances ne sont toutefois que les valeurs lues pendant la dernière exécution : si l'effet ne lit quelque chose que dans une branche if qui ne s'est pas exécutée, un changement de cette valeur ne le réveillera pas. Méfie-toi aussi des effets asynchrones, car seules les lectures faites avant le premier await sont suivies. Un watcher créé dans <script setup> s'arrête tout seul au démontage du composant, et tu verras le détail de l'arrêt dans la prochaine leçon.

watch vs watchEffect

Les deux outils savent faire le même travail, ils diffèrent par leur façon de fonctionner. Compare-les sur un seul compteur, observé de deux manières en même temps.

1import { ref, watch, watchEffect } from 'vue'
2
3const count = ref(0)
4
5// watch - source explicite, accès à oldValue
6watch(count, (newVal, oldVal) => {
7  console.log(`${oldVal} → ${newVal}`)
8})
9
10// watchEffect - suivi automatique, pas de oldValue
11watchEffect(() => {
12  console.log(`Count: ${count.value}`)
13})

watch reçoit sa source explicitement et fournit l'ancienne valeur, donc après le changement du compteur il affiche 0 → 1. watchEffect affiche simplement Count: 0 dès le démarrage, puis Count: 1, car il ne connaît pas l'ancienne valeur : il ne voit que l'état courant. Les différences, résumées dans un tableau :

CaractéristiquewatchwatchEffect
SourceExpliciteAutomatique
oldValueOuiNon
immediateOptionnelToujours
LazyOuiNon

La ligne "Lazy" signifie que watch attend par défaut le premier changement, alors que watchEffect démarre immédiatement. Lequel choisir ? Je te recommande watch dans le code que d'autres liront : la source est visible dès la première ligne, comme dans la fiche technique d'un capteur. Utilise watchEffect quand l'effet se sert de plusieurs valeurs, que chacune doit le déclencher et que l'ancienne valeur n'est pas nécessaire.

Nettoyage (cleanup) dans watchEffect

Les effets lancent souvent un travail qui dure : une requête au serveur, un intervalle, une connexion. Quand id change avant le retour de la requête précédente, la réponse tardive peut écraser la nouvelle. C'est pourquoi l'effet reçoit la fonction onCleanup comme premier argument. Tu y enregistres un nettoyage, et Vue l'appelle avant la prochaine exécution de l'effet et à l'arrêt du watcher. C'est un AbortController qui annule la requête : tu passes son champ signal à fetch, et sa méthode abort() interrompt la requête en cours.

1<script setup>
2import { ref, watchEffect } from 'vue'
3
4const id = ref(1)
5
6watchEffect((onCleanup) => {
7  const controller = new AbortController()
8
9  fetch(`/api/artwork/${id.value}`, {
10    signal: controller.signal
11  })
12    .then(res => res.json())
13    .then(data => console.log(data))
14
15  // Cleanup - appelé avant chaque nouvelle exécution
16  onCleanup(() => {
17    controller.abort()
18  })
19})
20</script>

Quand id passe de 1 à 2, Vue appelle d'abord le nettoyage et interrompt la requête pour le numéro 1, puis seulement ensuite il exécute l'effet pour le numéro 2. Le nettoyage ne saute aucune exécution : l'effet réagit toujours à chaque changement. Un fetch interrompu rejette sa promesse avec une erreur AbortError, donc dans un vrai panneau, ajoute la gestion de cette erreur précise. Tu verras le modèle complet dans la prochaine leçon. Depuis Vue 3.5, il existe aussi une fonction importable onWatcherCleanup(), mais il faut l'appeler de manière synchrone, avant le premier await.

watchPostEffect et watchSyncEffect

Par défaut, watchEffect fonctionne en mode flush: 'pre' : il exécute l'effet avant la mise à jour du DOM. Quand tu as besoin de lire le DOM déjà rafraîchi, par exemple pour mesurer la hauteur d'un panneau après l'ajout d'une ligne, utilise watchPostEffect, c'est-à-dire watchEffect avec flush: 'post'. watchSyncEffect est la variante avec flush: 'sync', exécutée de manière synchrone à chaque changement individuel.

1import { watchEffect, watchPostEffect, watchSyncEffect } from 'vue'
2
3// Par défaut - avant la mise à jour du DOM
4watchEffect(() => { /* ... */ })
5
6// Après la mise à jour du DOM
7watchPostEffect(() => {
8  // Le DOM est déjà mis à jour ici
9})
10
11// De manière synchrone - immédiatement au changement
12watchSyncEffect(() => {
13  // Attention : peut impacter les performances
14})

L'ordre, du plus tôt au plus tard, est fixe : sync immédiatement au changement, pre avant la mise à jour du DOM, post après. La variante synchrone ne regroupe pas les changements, donc trois affectations à la suite donnent trois exécutions de l'effet. Considère-la comme une exception, pas comme le choix par défaut.

Souviens-toi : watchEffect est le pilote automatique de la télémétrie - il détecte lui-même les capteurs qu'il utilise et démarre tout de suite, sans attendre le premier changement.

Code de cette leçon : App.vue
1<script setup>
2import { ref, watchEffect } from 'vue'
3
4// NOVA LAB - Système de suivi automatique avec watchEffect()
5const solarPanelAngle = ref(45)
6const sunIntensity = ref(70)
7const batteryCharge = ref(85)
8const powerConsumption = ref(1200)
9
10const logs = ref([])
11
12// watchEffect - suit automatiquement TOUTES les dépendances réactives
13watchEffect(() => {
14  // Ceci sera relancé dès que N'IMPORTE QUELLE dépendance change
15  const effectivePower = Math.floor(
16    (solarPanelAngle.value / 90) * sunIntensity.value * 30
17  )
18
19  const netPower = effectivePower - (powerConsumption.value / 100)
20
21  const timestamp = new Date().toLocaleTimeString()
22  logs.value.unshift({
23    time: timestamp,
24    message: `Production d'énergie : ${effectivePower}W | Net : ${netPower > 0 ? '+' : ''}${netPower.toFixed(0)}W`,
25    color: netPower > 0 ? '#00ff88' : '#ff0044'
26  })
27
28  // Ajuste automatiquement la batterie selon l'énergie nette
29  if (netPower > 0 && batteryCharge.value < 100) {
30    batteryCharge.value = Math.min(100, batteryCharge.value + 0.1)
31  } else if (netPower < 0 && batteryCharge.value > 0) {
32    batteryCharge.value = Math.max(0, batteryCharge.value - 0.1)
33  }
34})
35
36// Suit l'état de la batterie séparément
37watchEffect(() => {
38  if (batteryCharge.value < 20) {
39    const timestamp = new Date().toLocaleTimeString()
40    logs.value.unshift({
41      time: timestamp,
42      message: 'ATTENTION : batterie très faible !',
43      color: '#ff0044'
44    })
45  }
46})
47
48const effectivePower = ref(0)
49
50watchEffect(() => {
51  effectivePower.value = Math.floor(
52    (solarPanelAngle.value / 90) * sunIntensity.value * 30
53  )
54})
55
56function optimizePanels() {
57  solarPanelAngle.value = 90
58}
59
60function nightMode() {
61  sunIntensity.value = 5
62  solarPanelAngle.value = 0
63}
64
65function dayMode() {
66  sunIntensity.value = 85
67  solarPanelAngle.value = 65
68}
69</script>
70
71<template>
72  <div class="auto-track">
73    <div class="header">
74      <h1>NOVA LAB Solar Power System</h1>
75      <p class="subtitle">watchEffect() - suivi automatique des dépendances</p>
76    </div>
77
78    <div class="visual-panel">
79      <div class="solar-panel" :style="{ transform: `rotate(${solarPanelAngle}deg)` }">
80        <div class="panel-surface">
81          <div class="panel-grid"></div>
82          <div class="angle-display">{{ solarPanelAngle }}°</div>
83        </div>
84      </div>
85      <div class="sun-indicator" :style="{ opacity: sunIntensity / 100 }">
86        ●
87        <div class="sun-rays"></div>
88      </div>
89    </div>
90
91    <div class="controls-grid">
92      <div class="control-card">
93        <h3>Intensité du soleil</h3>
94        <input
95          v-model.number="sunIntensity"
96          type="range"
97          min="0"
98          max="100"
99          class="slider"
100        />
101        <div class="value-display">{{ sunIntensity }}%</div>
102      </div>
103
104      <div class="control-card">
105        <h3>Angle du panneau</h3>
106        <input
107          v-model.number="solarPanelAngle"
108          type="range"
109          min="0"
110          max="90"
111          class="slider"
112        />
113        <div class="value-display">{{ solarPanelAngle }}°</div>
114      </div>
115
116      <div class="control-card">
117        <h3>Consommation d'énergie</h3>
118        <input
119          v-model.number="powerConsumption"
120          type="range"
121          min="500"
122          max="2500"
123          step="100"
124          class="slider"
125        />
126        <div class="value-display">{{ powerConsumption }}W</div>
127      </div>
128
129      <div class="control-card battery">
130        <h3>Charge de la batterie</h3>
131        <div class="battery-indicator">
132          <div
133            class="battery-fill"
134            :style="{
135              width: batteryCharge + '%',
136              background: batteryCharge > 50 ? '#00ff88' : batteryCharge > 20 ? '#ffaa00' : '#ff0044'
137            }"
138          ></div>
139        </div>
140        <div class="value-display">{{ batteryCharge.toFixed(1) }}%</div>
141      </div>
142    </div>
143
144    <div class="stats-panel">
145      <div class="stat">
146        <span class="stat-label">Énergie effective :</span>
147        <span class="stat-value power">{{ effectivePower }}W</span>
148      </div>
149      <div class="stat">
150        <span class="stat-label">Énergie nette :</span>
151        <span
152          class="stat-value"
153          :style="{ color: effectivePower - powerConsumption / 100 > 0 ? '#00ff88' : '#ff0044' }"
154        >
155          {{ effectivePower - Math.floor(powerConsumption / 100) > 0 ? '+' : '' }}{{ effectivePower - Math.floor(powerConsumption / 100) }}W
156        </span>
157      </div>
158    </div>
159
160    <div class="action-buttons">
161      <button @click="optimizePanels" class="optimize">Optimiser les panneaux</button>
162      <button @click="nightMode" class="night">Mode nuit</button>
163      <button @click="dayMode" class="day">Mode jour</button>
164    </div>
165
166    <div class="log-panel">
167      <h3>Journal de suivi automatique</h3>
168      <div class="log-list">
169        <div
170          v-for="(log, index) in logs.slice(0, 10)"
171          :key="index"
172          class="log-item"
173          :style="{ color: log.color }"
174        >
175          <span class="log-time">[{{ log.time }}]</span> {{ log.message }}
176        </div>
177      </div>
178    </div>
179
180    <div class="info-box">
181      <strong>watchEffect() :</strong> suit automatiquement toutes les dépendances réactives !
182      Pas besoin de préciser quoi observer - il le détecte tout seul.
183    </div>
184  </div>
185</template>
186
187<style>
188* {
189  margin: 0;
190  padding: 0;
191  box-sizing: border-box;
192}
193
194body {
195  font-family: 'Courier New', monospace;
196  background: #0a0e27;
197  color: #00ff88;
198  padding: 20px;
199}
200
201.auto-track {
202  max-width: 1000px;
203  margin: 0 auto;
204}
205
206.header {
207  text-align: center;
208  margin-bottom: 30px;
209  padding: 20px;
210  background: linear-gradient(135deg, #1a1f4c 0%, #0a0e27 100%);
211  border: 2px solid #00ff88;
212  border-radius: 10px;
213}
214
215.header h1 {
216  font-size: 2rem;
217  margin-bottom: 10px;
218  text-shadow: 0 0 20px #00ff88;
219}
220
221.subtitle {
222  color: #00b4d8;
223  font-size: 1.1rem;
224}
225
226.visual-panel {
227  display: flex;
228  justify-content: space-around;
229  align-items: center;
230  padding: 40px;
231  margin-bottom: 30px;
232  background: rgba(0, 180, 216, 0.05);
233  border: 2px solid #00b4d8;
234  border-radius: 10px;
235  position: relative;
236}
237
238.solar-panel {
239  width: 150px;
240  height: 200px;
241  transition: transform 0.5s ease;
242  transform-origin: center center;
243}
244
245.panel-surface {
246  width: 100%;
247  height: 100%;
248  background: linear-gradient(135deg, #001a33 0%, #003366 100%);
249  border: 3px solid #00b4d8;
250  border-radius: 10px;
251  position: relative;
252  display: flex;
253  align-items: center;
254  justify-content: center;
255}
256
257.panel-grid {
258  position: absolute;
259  width: 90%;
260  height: 90%;
261  background-image:
262    repeating-linear-gradient(0deg, transparent, transparent 30px, #00b4d8 30px, #00b4d8 31px),
263    repeating-linear-gradient(90deg, transparent, transparent 30px, #00b4d8 30px, #00b4d8 31px);
264}
265
266.angle-display {
267  position: relative;
268  z-index: 1;
269  font-size: 1.5rem;
270  font-weight: bold;
271  color: #00ff88;
272  text-shadow: 0 0 10px #00ff88;
273}
274
275.sun-indicator {
276  font-size: 5rem;
277  filter: drop-shadow(0 0 30px #ffaa00);
278  transition: opacity 0.3s ease;
279  position: relative;
280}
281
282.controls-grid {
283  display: grid;
284  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
285  gap: 20px;
286  margin-bottom: 20px;
287}
288
289.control-card {
290  background: rgba(0, 180, 216, 0.05);
291  border: 2px solid #00b4d8;
292  border-radius: 10px;
293  padding: 20px;
294  text-align: center;
295}
296
297.control-card h3 {
298  color: #00b4d8;
299  margin-bottom: 15px;
300  font-size: 1rem;
301}
302
303.slider {
304  width: 100%;
305  margin: 15px 0;
306}
307
308.value-display {
309  font-size: 1.5rem;
310  font-weight: bold;
311  color: #00ff88;
312}
313
314.battery-indicator {
315  width: 100%;
316  height: 30px;
317  background: rgba(0, 0, 0, 0.5);
318  border: 2px solid #00ff88;
319  border-radius: 5px;
320  overflow: hidden;
321  margin: 15px 0;
322}
323
324.battery-fill {
325  height: 100%;
326  transition: all 0.3s ease;
327}
328
329.stats-panel {
330  display: flex;
331  justify-content: space-around;
332  padding: 20px;
333  margin-bottom: 20px;
334  background: rgba(0, 255, 136, 0.05);
335  border: 2px solid #00ff88;
336  border-radius: 10px;
337}
338
339.stat {
340  text-align: center;
341}
342
343.stat-label {
344  display: block;
345  color: #00b4d8;
346  margin-bottom: 10px;
347}
348
349.stat-value {
350  font-size: 2rem;
351  font-weight: bold;
352  text-shadow: 0 0 10px currentColor;
353}
354
355.stat-value.power {
356  color: #00ff88;
357}
358
359.action-buttons {
360  display: flex;
361  gap: 15px;
362  justify-content: center;
363  margin-bottom: 20px;
364}
365
366.action-buttons button {
367  padding: 15px 30px;
368  font-size: 1rem;
369  background: linear-gradient(135deg, #00b4d8 0%, #0077aa 100%);
370  color: white;
371  border: none;
372  border-radius: 5px;
373  cursor: pointer;
374  font-family: 'Courier New', monospace;
375  font-weight: bold;
376  transition: all 0.3s ease;
377}
378
379.action-buttons button:hover {
380  transform: translateY(-2px);
381  box-shadow: 0 5px 15px rgba(0, 180, 216, 0.3);
382}
383
384.optimize {
385  background: linear-gradient(135deg, #00ff88 0%, #00cc66 100%) !important;
386}
387
388.night {
389  background: linear-gradient(135deg, #333366 0%, #1a1a33 100%) !important;
390}
391
392.day {
393  background: linear-gradient(135deg, #ffaa00 0%, #ff8800 100%) !important;
394}
395
396.log-panel {
397  background: rgba(0, 0, 0, 0.5);
398  border: 2px solid #00ff88;
399  border-radius: 10px;
400  padding: 20px;
401  margin-bottom: 20px;
402}
403
404.log-panel h3 {
405  color: #00ff88;
406  margin-bottom: 15px;
407}
408
409.log-list {
410  max-height: 200px;
411  overflow-y: auto;
412}
413
414.log-item {
415  padding: 8px;
416  margin-bottom: 5px;
417  background: rgba(0, 255, 136, 0.05);
418  border-radius: 4px;
419  font-size: 0.9rem;
420  animation: fadeIn 0.3s ease;
421}
422
423@keyframes fadeIn {
424  from { opacity: 0; }
425  to { opacity: 1; }
426}
427
428.log-time {
429  opacity: 0.7;
430}
431
432.info-box {
433  background: rgba(0, 255, 136, 0.1);
434  border: 2px solid #00ff88;
435  border-radius: 10px;
436  padding: 15px;
437  text-align: center;
438  color: #00ff88;
439  line-height: 1.6;
440}
441</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. Quelle est la différence essentielle entre watch et watchEffect ?

  2. 2. Quand watchEffect s'exécute-t-il pour la première fois ?

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 watchEffect :

  • Éditeur de code

    Complète le watchEffect qui suit automatiquement les changements de plusieurs refs et les journalise.

  • Tri vertical

    Ordonne le cycle de vie de watchEffect, de la création au nettoyage :

  • Éditeur de code

    Complète le watch qui observe un tableau de plusieurs sources [sensorA, sensorB, sensorC].