Cours Vue.js · Module 4: Computed et watch

Arrêter les watchers - fermer les canaux de télémétrie

16 min de lecture
Dans cette leçon7

L'équipe de nuit du Centre de Calcul de NOVA LAB a laissé un rapport d'incident. L'opérateur a fermé le panneau de la sonde Ares-3, il est passé au panneau du réacteur, et le journal de la console a continué de se remplir de relevés de pression venus d'une sonde que plus personne ne regarde. Personne n'appelle ce code, et pourtant il travaille toujours.

C'est exactement ainsi que se comporte un watcher. Ce n'est pas une fonction que tu lances une fois et que tu peux ensuite oublier - c'est un canal d'écoute ouvert, branché sur une source réactive. Tant que personne ne le ferme, ce canal occupe de la mémoire, exécute son callback à chaque changement et peut réécrire les données d'un panneau réglé depuis longtemps sur une tout autre fréquence. Dr. Nova le résume en une phrase : tout canal ouvert dans le Centre de Contrôle doit avoir un interrupteur. Dans cette leçon tu vas découvrir cet interrupteur, comprendre quand Vue l'actionne à ta place et quand tu dois le faire toi-même, et apprendre à faire le ménage derrière le travail qu'un watcher a eu le temps de lancer.

Cycle de vie d'un watcher

Avant de saisir l'interrupteur, remettons la chronologie en ordre, car sans elle il est difficile de comprendre ce que tu éteins exactement. Un watcher traverse quatre étapes, et toujours dans le même ordre.

La première étape est la création du watcher : tu appelles watch() ou watchEffect(). La deuxième est l'enregistrement des dépendances et l'écoute des changements : Vue retient quelles sources réactives ont été lues et s'y branche, comme l'officier de quart inscrit la fréquence de la sonde dans sa console. La troisième étape est l'exécution du callback au changement : tant que le canal reste ouvert, chaque modification de la source déclenche ta fonction. La quatrième est l'arrêt, c'est-à-dire l'appel de stop() ou le démontage du composant.

Les étapes deux et trois se répètent autant de fois qu'il le faut, pendant toute la vie du panneau. La quatrième arrive exactement une fois et elle est irréversible : un watcher arrêté ne se rallume pas, car il n'existe rien qui ressemble à un start(). Si le canal doit émettre de nouveau, il faut le rouvrir en créant un watcher tout neuf. La pause, que tu verras à la fin de la leçon, fait exception, mais mettre en pause n'est pas la même chose qu'arrêter.

L'interrupteur qu'on te remet en échange

Où trouver cet interrupteur ? Inutile de le chercher : Vue te le tend au moment même où tu ouvres le canal. L'appel watch() retourne une fonction, et l'appel watchEffect() retourne lui aussi une fonction. Ce n'est ni un objet observateur ni la valeur observée, mais une simple fonction sans argument, que l'usage appelle stop function. Tant que tu ne la ranges pas dans une variable, l'interrupteur tombe dans le vide et plus rien ne pourra fermer ce canal. Voici un panneau de sonde minimal, avec un compteur de puissance du signal et un bouton qui referme le canal.

1<template>
2  <p>Signal: {{ signalStrength }}</p>
3  <button @click="signalStrength++">Boost signal</button>
4  <button @click="stopChannel()">Close channel</button>
5</template>
6
7<script setup>
8import { ref, watch } from 'vue'
9
10const signalStrength = ref(0)
11
12// watch retourne une fonction stop - garde-la dans une variable
13const stopChannel = watch(signalStrength, (value) => {
14  console.log('Signal:', value)
15})
16</script>

Clique sur le premier bouton et surveille la console : à chaque clic une nouvelle ligne apparaît. Clique sur le second bouton, puis continue de cliquer sur le premier - la console reste muette. Et maintenant le plus important, c'est-à-dire ce qui n'a pas changé. Le compteur signalStrength est toujours réactif, le paragraphe du template affiche toujours le nombre à jour, et le bouton continue de l'incrémenter. Arrêter un watcher n'invalide pas les données et ne gèle pas la vue : seule l'écoute s'éteint. Dans ce panneau, c'est la seule chose que fait stopChannel(), et c'est en même temps le seul moyen d'arrêter un watcher à la main.

Autant redresser tout de suite trois malentendus, car ils circulent dans les équipes qui arrivent à Vue depuis d'autres outils. Premièrement, il n'existe aucune méthode watcher.destroy() : watch() ne retourne pas un objet sur lequel on pourrait appeler quoi que ce soit, mais une fonction, donc il n'y a rien à atteindre avec un point. Deuxièmement, il est faux de dire qu'un watcher ne peut pas être arrêté manuellement - tu viens de le faire en une seule ligne. Troisièmement, redémarrer le composant supprime bel et bien ses watchers, et nous allons exploiter ce mécanisme dans un instant, mais forcer le rechargement d'un panneau entier pour faire taire un seul canal revient à couper l'alimentation d'un module pour éteindre un voyant : ça marche, et au passage ça détruit tout l'état du panneau.

Vue ferme les canaux à ta place

Avant que tu ne prennes le réflexe de stocker la poignée de chaque watcher, une bonne nouvelle : dans la grande majorité des cas, tu n'en as pas besoin. Un watcher créé de manière synchrone dans <script setup> est rattaché à l'instance du composant dans laquelle il est né. Quand ce composant est démonté, c'est-à-dire quand il disparaît de la page (unmount), Vue arrête tous les watchers qui lui appartiennent. Le panneau de la sonde s'éteint avec ses canaux, et aucun d'eux ne reste ouvert en arrière-plan.

Retiens bien ce moment précis, car l'erreur est vite arrivée. Les watchers ne s'arrêtent pas après la première invocation du callback : ce comportement se commande séparément, avec l'option once: true disponible depuis Vue 3.4, que tu as croisée dans les options de watch. Ils ne s'arrêtent pas non plus après un temps d'inactivité : Vue ne compte rien du tout, il n'y a ni limite d'une minute ni minuterie d'extinction. Et ils n'ont certainement pas besoin d'être toujours arrêtés à la main, sinon le moindre composant distrait laisserait derrière lui une fuite de mémoire.

Il existe pourtant une exception, et c'est elle qui explique le rapport de l'équipe de nuit. Le rattachement au composant ne vaut que pour les watchers créés de manière synchrone, c'est-à-dire à l'instant où Vue exécute le code de <script setup>. Un watcher né plus tard - dans un setTimeout, en réponse à un événement ou après un await dans une fonction asynchrone - apparaît en dehors de cette fenêtre et n'a aucun propriétaire. Voici deux canaux au contenu identique, qui ne diffèrent que par leur moment d'ouverture.

1<script setup>
2import { ref, watchEffect } from 'vue'
3
4const pressure = ref(1013)
5
6// cree de maniere synchrone dans setup - appartient a ce composant
7watchEffect(() => {
8  console.log('Pressure:', pressure.value)
9})
10
11setTimeout(() => {
12  // cree une seconde plus tard - celui-ci n'appartient a personne
13  watchEffect(() => {
14    console.log('Delayed pressure:', pressure.value)
15  })
16}, 1000)
17</script>

Les deux watchers sont de vrais watchers : tous deux suivent pressure de la même façon, tous deux exécutent leur callback immédiatement après leur création puis à chaque changement suivant. La différence n'apparaît qu'à la fermeture du panneau. Le premier canal s'éteint avec le composant. Le second continue d'émettre, et maintient au passage en vie tout ce à quoi il fait référence - c'est exactement cela, une fuite de mémoire, et c'est précisément la ligne du journal que l'officier de quart n'arrivait pas à expliquer.

Puisque Vue ne fermera pas ce canal, c'est à nous de le faire. La poignée retournée par watchEffect() doit être conservée dans une variable visible en dehors de la fonction de rappel, et l'extinction elle-même doit être branchée sur le hook onUnmounted, la fonction de la famille des hooks de cycle de vie que Vue appelle juste après le retrait du composant de la page.

1<script setup>
2import { ref, watchEffect, onUnmounted } from 'vue'
3
4const pressure = ref(1013)
5let stopDelayed = null
6
7setTimeout(() => {
8  stopDelayed = watchEffect(() => {
9    console.log('Delayed pressure:', pressure.value)
10  })
11}, 1000)
12
13onUnmounted(() => {
14  if (stopDelayed) {
15    stopDelayed()
16  }
17})
18</script>

La variable stopDelayed est déclarée avec let et non avec const, parce que la poignée n'y arrive qu'une seconde plus tard. C'est aussi pour cette raison que la condition est nécessaire : si l'opérateur ferme le panneau avant que le setTimeout ne se déclenche, la variable contient encore null et l'appeler comme une fonction lèverait une erreur. Pour le reste, rien n'a changé dans ce bloc - le watcher suit les mêmes données et exécute le même callback, il a seulement gagné un plan de secours pour le jour où le panneau disparaît.

Profitons-en pour désamorcer un motif que tu croiseras dans du code plus ancien : créer un watcher classique dans onMounted et l'arrêter à la main dans onUnmounted. Le hook onMounted s'exécute alors que le composant est actif, donc dans les versions actuelles de Vue un tel watcher reçoit malgré tout un propriétaire et sera arrêté automatiquement. L'appel manuel de stop() n'y casse rien, car arrêter une seconde fois un watcher déjà arrêté ne fait rien du tout, mais il ne sauve rien non plus. Ce qui te protège d'une fuite, c'est de mémoriser la poignée des canaux ouverts de façon asynchrone, pas le simple fait de faire le ménage dans onUnmounted.

Un watcher qui s'éteint tout seul

Il reste une troisième situation, à côté de l'interrupteur sous un bouton et du ménage au démontage. Parfois un canal a une mission définie d'avance : on attend un état précis et, une fois cet état détecté, l'écoute n'a plus de sens. La station reçoit des paquets de télémétrie du rover, et seul nous intéresse le moment où le paquet devient complet. Le watcher peut alors se refermer avec sa propre poignée, en l'appelant depuis l'intérieur de son callback.

1<script setup>
2import { ref, watch } from 'vue'
3
4const telemetry = ref(null)
5
6const stopWhenComplete = watch(telemetry, (packet) => {
7  if (packet && packet.complete) {
8    console.log('Telemetry complete - closing channel')
9    stopWhenComplete()
10  }
11})
12</script>

On dirait un serpent qui se mord la queue, et pourtant c'est parfaitement sûr. Au moment de l'appel de watch(), le callback ne s'exécute pas encore : Vue se contente d'enregistrer les dépendances. Avant le premier changement de telemetry, la constante stopWhenComplete est déjà initialisée et pointe vers une poignée prête à l'emploi. La condition à l'intérieur vérifie deux choses à la fois : si un paquet est bien arrivé, puisque la valeur initiale est null, et s'il porte le drapeau complete. Une fois le canal fermé, telemetry peut encore être remplacée autant de fois que tu veux - la réactivité fonctionne toujours, simplement plus personne ne l'écoute.

Un piège, qu'il vaut mieux ne pas découvrir soi-même dans un panneau de production. Si tu ajoutes l'option immediate: true à un watcher de ce genre, le callback s'exécutera aussitôt, pendant l'appel de watch(), donc avant que la constante n'ait reçu sa valeur : la référence à stopWhenComplete lèvera alors une erreur d'utilisation de variable avant initialisation. Si ton watcher doit réagir exactement une fois puis disparaître, je te recommande d'utiliser l'option once: true de Vue 3.4 plutôt que l'auto-arrêt : l'intention se lit immédiatement dans les options, et Vue referme le canal tout seul après le premier appel. Garde l'auto-arrêt manuel pour les cas à condition, comme le drapeau complete ci-dessus.

onCleanup, le ménage d'un travail interrompu

Jusqu'ici nous fermions un canal qui ne laissait rien derrière lui. Dans la vraie vie, les watchers lancent du travail qui dure : récupération de données depuis un serveur, décompte d'un intervalle, écoute d'un WebSocket. Imagine un panneau qui, à chaque changement de numéro de sonde, démarre une interrogation toutes les secondes. L'opérateur saute d'une sonde à l'autre trois fois, le callback s'exécute trois fois, et voilà trois intervalles indépendants qui tictaquent en arrière-plan alors qu'un seul panneau est affiché.

Vue propose un seul mécanisme pour cela, et il est intégré au callback lui-même. La fonction passée à watch() peut accepter un troisième argument, appelé par convention onCleanup. C'est une fonction que tu appelles à l'intérieur du callback, en lui donnant ta propre fonction de nettoyage. Vue la mémorise et l'appellera à deux moments : juste avant la prochaine exécution du callback, et à l'instant où le watcher est arrêté. L'ordre des arguments est figé, donc onCleanup se trouve toujours en troisième position, après la nouvelle et l'ancienne valeur.

1<script setup>
2import { ref, watch } from 'vue'
3
4const probeId = ref(1)
5
6watch(probeId, (newId, oldId, onCleanup) => {
7  const timer = setInterval(() => {
8    console.log('Polling probe', newId)
9  }, 1000)
10
11  onCleanup(() => {
12    clearInterval(timer)
13    console.log('Polling stopped for probe', newId)
14  })
15})
16</script>

Change de sonde plusieurs fois et compte les lignes dans la console : à un instant donné, une seule sonde est interrogée, car avant chaque nouvelle exécution Vue efface l'intervalle précédent. Remarque bien ce que onCleanup ne fait pas. Il ne saute aucune exécution du callback : le watcher réagit à chaque changement exactement comme avant. Il n'arrête pas non plus le watcher, car nettoyer et fermer un canal sont deux opérations différentes. Note aussi l'ordre : Vue lance la fonction de nettoyage avant que le callback ne reparte, donc l'ancien intervalle s'éteint avant que le nouveau n'existe. Ce nettoyage va chercher le timer de la passe dans laquelle il a été enregistré - chaque exécution du callback possède son propre jeu de variables et ne nettoie que derrière elle-même.

Puisque le nettoyage n'a qu'une seule forme correcte, énumérons les idées qui ne marcheront pas. Retourner une fonction de nettoyage depuis le callback, comme dans le useEffect bien connu de React, est du code mort dans Vue : la valeur retournée par le callback d'un watcher est ignorée, et avec un callback asynchrone ce serait de toute façon une promesse, pas une fonction. Il n'existe pas non plus de fonction globale useCleanup() - dans l'écosystème Vue, le préfixe use désigne un composable, c'est-à-dire une fonction que tu écris toi-même, pas un élément de l'API du framework. Enfin, il est faux de dire que watch ne prend pas en charge le nettoyage : il le prend en charge exactement comme watchEffect, avec une seule différence de position. Dans watchEffect, qui ne reçoit ni nouvelle ni ancienne valeur, onCleanup est le premier argument, et c'est ainsi qu'était écrit l'exemple avec AbortController de la leçon précédente. Dans watch, il est le troisième.

Poignée et nettoyage dans un même panneau

Combinons les deux outils, car dans un vrai panneau de mission ils vont ensemble. Un canal de synchronisation récupère le relevé de la sonde choisie depuis le serveur, l'opérateur dispose d'un bouton pour couper la synchronisation, et chaque changement de sonde doit annuler la requête qui n'est pas encore revenue. Pour cette annulation nous utiliserons AbortController : un objet du navigateur qui expose un champ signal, transmis à fetch, et une méthode abort() qui interrompt la requête en cours. Un fetch interrompu rejette sa promesse avec une erreur nommée AbortError, donc nous faisons taire ce cas précis, car ce n'est pas une panne mais la confirmation que le nettoyage a fonctionné.

1<template>
2  <p>Probe: {{ probeId }}</p>
3  <button @click="probeId++">Next probe</button>
4  <button @click="stopSync()">Stop sync</button>
5</template>
6
7<script setup>
8import { ref, watch } from 'vue'
9
10const probeId = ref(1)
11const reading = ref(null)
12
13const stopSync = watch(probeId, async (newId, oldId, onCleanup) => {
14  const controller = new AbortController()
15  onCleanup(() => controller.abort())
16
17  try {
18    const response = await fetch(`/api/nova-lab/probes/${newId}`, {
19      signal: controller.signal
20    })
21    reading.value = await response.json()
22  } catch (error) {
23    if (error.name !== 'AbortError') {
24      throw error
25    }
26  }
27})
28</script>

Voilà un motif qu'il vaut la peine d'avoir dans les doigts : d'abord watch(source,, ensuite le callback à trois arguments (newVal, oldVal, onCleanup) => {, puis, dès la première ligne du corps, l'enregistrement du nettoyage onCleanup(() => controller.abort()), et enfin la fermeture }). Place cet enregistrement juste après la création du contrôleur, avant le premier await - sinon un changement de sonde très rapide tombera dans la fenêtre où la requête est déjà partie alors que Vue ne sait pas encore comment l'interrompre. L'argument oldId reste dans la signature bien que nous ne l'utilisions pas, car on ne peut pas sauter une position dans la liste des arguments.

Observe maintenant l'effet le plus intéressant de cette combinaison. Un clic sur le bouton de coupure appelle stopSync(), et Vue, en arrêtant le watcher, exécute la dernière fonction de nettoyage enregistrée : la requête en cours est annulée, alors que personne ne l'a demandé séparément. Fermer le canal emporte donc avec lui le travail que ce canal avait eu le temps de commander. En revanche, cela ne change pas la valeur de reading : les données déjà récupérées restent dans le panneau, et la seule chose qui disparaît, c'est la disposition à réagir aux prochains changements de sonde.

Mettre en pause plutôt qu'éteindre (Vue 3.5+)

Terminons par une situation où stop() est un outil trop définitif. Pendant une tempête de poussière, le capteur d'empoussièrement s'emballe et noie le journal sous des centaines d'entrées. Nous ne voulons pas fermer le canal, car il redeviendra utile dans vingt minutes, et recréer le watcher signifierait dupliquer la même configuration à deux endroits du code. Nous voulons le rendre muet. Depuis Vue 3.5, la poignée retournée par watch() et watchEffect() reste bien une fonction, appelable comme avant, mais elle porte en plus trois méthodes : stop, pause et resume. On peut donc la déballer par destructuration et se servir de chacune séparément.

1import { ref, watch } from 'vue'
2
3const dustLevel = ref(12)
4
5const { stop, pause, resume } = watch(dustLevel, (value) => {
6  console.log('Dust index:', value)
7})
8
9// couper le canal pendant la tempete
10pause()
11
12// le remettre en service
13resume()
14
15// le fermer definitivement
16stop()

L'ordre dans ce bloc n'a rien d'anodin et mérite d'être retenu : d'abord la création avec destructuration, puis la pause, puis la reprise, et l'arrêt toujours en dernier, car après lui aucune des autres méthodes n'a plus rien à gérer. Après pause(), le watcher existe toujours et continue de suivre dustLevel - seule l'exécution du callback est suspendue. Si la source a changé pendant la pause, l'appel de resume() déclenchera le callback une fois, avec la valeur courante, si bien que le panneau rattrape son retard au lieu d'ignorer toute la tempête.

Deux remarques pratiques. Dans les versions de Vue antérieures à 3.5, ces méthodes n'existent tout simplement pas et tout ce que tu reçois est une fonction d'arrêt : la mise en sourdine se fait alors avec un simple drapeau testé au début du callback. Et même quand ces méthodes sont disponibles, pause() ne remplace pas stop() : un watcher suspendu occupe toujours de la mémoire et reste branché sur ses sources, donc un canal auquel tu ne comptes pas revenir, ferme-le pour de bon.

Souviens-toi : un watcher est un canal de télémétrie ouvert du Centre de Contrôle - Vue l'éteint avec le panneau, mais un canal ouvert hors du bon moment, tu ne le fermeras qu'avec la poignée que tu auras pensé à garder.

Code de cette leçon : App.vue
1<script setup>
2import { ref, watch } from 'vue'
3
4// NOVA LAB - Observation profonde de données de mission complexes
5const missionData = ref({
6  crew: {
7    commander: 'Sarah Chen',
8    engineer: 'Marcus Rodriguez',
9    scientist: 'Dr. Yuki Tanaka',
10    medic: 'Dr. James Wilson'
11  },
12  systems: {
13    life_support: {
14      oxygen: 95,
15      temperature: 22,
16      humidity: 45
17    },
18    power: {
19      solar: 2800,
20      battery: 85,
21      consumption: 2200
22    },
23    communication: {
24      earth_link: true,
25      satellite: true,
26      local: true
27    }
28  },
29  inventory: {
30    food: 450,
31    water: 3200,
32    medical: 87,
33    tools: 95
34  }
35})
36
37const changeLog = ref([])
38
39// Deep watch - détecte les changements dans les objets imbriqués
40watch(
41  missionData,
42  (newVal, oldVal) => {
43    const timestamp = new Date().toLocaleTimeString()
44    changeLog.value.unshift({
45      time: timestamp,
46      message: 'Données de mission mises à jour (changement profond détecté)',
47      color: '#00b4d8'
48    })
49  },
50  { deep: true } // Active l'observation profonde
51)
52
53// Watch sur une propriété imbriquée précise
54watch(
55  () => missionData.value.systems.life_support.oxygen,
56  (newVal, oldVal) => {
57    const timestamp = new Date().toLocaleTimeString()
58    changeLog.value.unshift({
59      time: timestamp,
60      message: `O2: ${oldVal}% → ${newVal}%`,
61      color: newVal < 80 ? '#ff0044' : '#00ff88'
62    })
63  }
64)
65
66// Watch avec l'option immediate
67watch(
68  () => missionData.value.systems.power.battery,
69  (newVal) => {
70    if (newVal < 30) {
71      const timestamp = new Date().toLocaleTimeString()
72      changeLog.value.unshift({
73        time: timestamp,
74        message: 'Batterie très faible !',
75        color: '#ff0044'
76      })
77    }
78  },
79  { immediate: true } // Exécute immédiatement au montage
80)
81
82function updateOxygen(delta) {
83  missionData.value.systems.life_support.oxygen = Math.max(0, Math.min(100,
84    missionData.value.systems.life_support.oxygen + delta
85  ))
86}
87
88function updateBattery(delta) {
89  missionData.value.systems.power.battery = Math.max(0, Math.min(100,
90    missionData.value.systems.power.battery + delta
91  ))
92}
93
94function updateInventory(item, delta) {
95  missionData.value.inventory[item] = Math.max(0,
96    missionData.value.inventory[item] + delta
97  )
98}
99
100function toggleComm(system) {
101  missionData.value.systems.communication[system] =
102    !missionData.value.systems.communication[system]
103}
104
105function emergencyProtocol() {
106  missionData.value.systems.life_support.oxygen = 65
107  missionData.value.systems.power.battery = 25
108  missionData.value.inventory.food -= 50
109  missionData.value.inventory.water -= 200
110}
111
112function restoreNominal() {
113  missionData.value.systems.life_support.oxygen = 95
114  missionData.value.systems.life_support.temperature = 22
115  missionData.value.systems.life_support.humidity = 45
116  missionData.value.systems.power.battery = 85
117  missionData.value.inventory.food = 450
118  missionData.value.inventory.water = 3200
119}
120</script>
121
122<template>
123  <div class="deep-watch">
124    <div class="header">
125      <h1>NOVA LAB Mission Control</h1>
126      <p class="subtitle">Observation profonde et exécution immédiate</p>
127    </div>
128
129    <div class="data-grid">
130      <div class="data-section crew">
131        <h3>Liste de l'équipage</h3>
132        <div class="crew-list">
133          <div v-for="(name, role) in missionData.crew" :key="role" class="crew-member">
134            <span class="role">{{ role }}:</span>
135            <span class="name">{{ name }}</span>
136          </div>
137        </div>
138      </div>
139
140      <div class="data-section life-support">
141        <h3>Support de vie</h3>
142        <div class="system-item">
143          <span>Oxygène :</span>
144          <span class="value">{{ missionData.systems.life_support.oxygen }}%</span>
145          <div class="mini-controls">
146            <button @click="updateOxygen(-5)">-</button>
147            <button @click="updateOxygen(5)">+</button>
148          </div>
149        </div>
150        <div class="system-item">
151          <span>Température :</span>
152          <span class="value">{{ missionData.systems.life_support.temperature }}°C</span>
153        </div>
154        <div class="system-item">
155          <span>Humidité :</span>
156          <span class="value">{{ missionData.systems.life_support.humidity }}%</span>
157        </div>
158      </div>
159
160      <div class="data-section power">
161        <h3>Systèmes d'énergie</h3>
162        <div class="system-item">
163          <span>Solaire :</span>
164          <span class="value">{{ missionData.systems.power.solar }}W</span>
165        </div>
166        <div class="system-item">
167          <span>Batterie :</span>
168          <span class="value" :style="{
169            color: missionData.systems.power.battery < 30 ? '#ff0044' : '#00ff88'
170          }">
171            {{ missionData.systems.power.battery }}%
172          </span>
173          <div class="mini-controls">
174            <button @click="updateBattery(-10)">-</button>
175            <button @click="updateBattery(10)">+</button>
176          </div>
177        </div>
178        <div class="system-item">
179          <span>Consommation :</span>
180          <span class="value">{{ missionData.systems.power.consumption }}W</span>
181        </div>
182      </div>
183
184      <div class="data-section comms">
185        <h3>Communications</h3>
186        <div class="comm-status">
187          <div
188            v-for="(status, system) in missionData.systems.communication"
189            :key="system"
190            class="comm-item"
191            @click="toggleComm(system)"
192          >
193            <span>{{ system.replace('_', ' ') }}:</span>
194            <span
195              class="status-indicator"
196              :class="{ active: status }"
197            >
198              {{ status ? 'EN LIGNE' : 'HORS LIGNE' }}
199            </span>
200          </div>
201        </div>
202      </div>
203
204      <div class="data-section inventory">
205        <h3>Inventaire</h3>
206        <div class="inventory-item" v-for="(amount, item) in missionData.inventory" :key="item">
207          <span>{{ item }}:</span>
208          <span class="value">{{ amount }}</span>
209          <div class="mini-controls">
210            <button @click="updateInventory(item, -10)">-</button>
211            <button @click="updateInventory(item, 10)">+</button>
212          </div>
213        </div>
214      </div>
215    </div>
216
217    <div class="action-bar">
218      <button @click="emergencyProtocol" class="emergency">Protocole d'urgence</button>
219      <button @click="restoreNominal" class="restore">Rétablir le nominal</button>
220    </div>
221
222    <div class="log-section">
223      <h3>Journal des changements profonds</h3>
224      <div class="log-container">
225        <div
226          v-for="(log, index) in changeLog.slice(0, 12)"
227          :key="index"
228          class="log-entry"
229          :style="{ color: log.color }"
230        >
231          <span class="log-time">[{{ log.time }}]</span> {{ log.message }}
232        </div>
233        <div v-if="changeLog.length === 0" class="no-logs">
234          Modifie n'importe quelle propriété imbriquée pour voir l'observation profonde en action...
235        </div>
236      </div>
237    </div>
238
239    <div class="info-box">
240      <strong>Observation profonde :</strong> utilise <code>{ deep: true }</code> pour observer les changements d'objets imbriqués.
241      Utilise <code>{ immediate: true }</code> pour lancer le watcher immédiatement au montage !
242    </div>
243  </div>
244</template>
245
246<style>
247* {
248  margin: 0;
249  padding: 0;
250  box-sizing: border-box;
251}
252
253body {
254  font-family: 'Courier New', monospace;
255  background: #0a0e27;
256  color: #00ff88;
257  padding: 20px;
258}
259
260.deep-watch {
261  max-width: 1200px;
262  margin: 0 auto;
263}
264
265.header {
266  text-align: center;
267  margin-bottom: 30px;
268  padding: 20px;
269  background: linear-gradient(135deg, #1a1f4c 0%, #0a0e27 100%);
270  border: 2px solid #00ff88;
271  border-radius: 10px;
272}
273
274.header h1 {
275  font-size: 2rem;
276  margin-bottom: 10px;
277  text-shadow: 0 0 20px #00ff88;
278}
279
280.subtitle {
281  color: #00b4d8;
282  font-size: 1.1rem;
283}
284
285.data-grid {
286  display: grid;
287  grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
288  gap: 20px;
289  margin-bottom: 20px;
290}
291
292.data-section {
293  background: rgba(0, 180, 216, 0.05);
294  border: 2px solid #00b4d8;
295  border-radius: 10px;
296  padding: 20px;
297}
298
299.data-section h3 {
300  color: #00b4d8;
301  margin-bottom: 15px;
302  font-size: 1.1rem;
303}
304
305.crew-list {
306  display: flex;
307  flex-direction: column;
308  gap: 10px;
309}
310
311.crew-member {
312  padding: 10px;
313  background: rgba(0, 255, 136, 0.05);
314  border-radius: 5px;
315}
316
317.role {
318  color: #00b4d8;
319  text-transform: capitalize;
320  margin-right: 10px;
321}
322
323.name {
324  color: #00ff88;
325  font-weight: bold;
326}
327
328.system-item,
329.inventory-item {
330  display: flex;
331  justify-content: space-between;
332  align-items: center;
333  padding: 10px;
334  margin-bottom: 8px;
335  background: rgba(0, 255, 136, 0.05);
336  border-radius: 5px;
337}
338
339.value {
340  color: #00ff88;
341  font-weight: bold;
342}
343
344.mini-controls {
345  display: flex;
346  gap: 5px;
347}
348
349.mini-controls button {
350  background: #00b4d8;
351  color: white;
352  border: none;
353  padding: 5px 12px;
354  border-radius: 3px;
355  cursor: pointer;
356  font-family: 'Courier New', monospace;
357  font-weight: bold;
358}
359
360.mini-controls button:hover {
361  background: #00ff88;
362}
363
364.comm-status {
365  display: flex;
366  flex-direction: column;
367  gap: 10px;
368}
369
370.comm-item {
371  display: flex;
372  justify-content: space-between;
373  padding: 10px;
374  background: rgba(0, 255, 136, 0.05);
375  border-radius: 5px;
376  cursor: pointer;
377  transition: all 0.3s ease;
378}
379
380.comm-item:hover {
381  background: rgba(0, 255, 136, 0.1);
382}
383
384.status-indicator {
385  font-weight: bold;
386}
387
388.status-indicator.active {
389  color: #00ff88;
390}
391
392.action-bar {
393  display: flex;
394  gap: 20px;
395  justify-content: center;
396  margin-bottom: 20px;
397}
398
399.action-bar button {
400  padding: 15px 40px;
401  font-size: 1rem;
402  border: none;
403  border-radius: 8px;
404  cursor: pointer;
405  font-family: 'Courier New', monospace;
406  font-weight: bold;
407  transition: all 0.3s ease;
408}
409
410.emergency {
411  background: linear-gradient(135deg, #ff0044 0%, #cc0033 100%);
412  color: white;
413}
414
415.restore {
416  background: linear-gradient(135deg, #00ff88 0%, #00cc66 100%);
417  color: white;
418}
419
420.action-bar button:hover {
421  transform: translateY(-2px);
422  box-shadow: 0 5px 20px rgba(0, 255, 136, 0.3);
423}
424
425.log-section {
426  background: rgba(0, 0, 0, 0.5);
427  border: 2px solid #00ff88;
428  border-radius: 10px;
429  padding: 20px;
430  margin-bottom: 20px;
431}
432
433.log-section h3 {
434  color: #00ff88;
435  margin-bottom: 15px;
436}
437
438.log-container {
439  max-height: 250px;
440  overflow-y: auto;
441}
442
443.log-entry {
444  padding: 8px;
445  margin-bottom: 5px;
446  background: rgba(0, 255, 136, 0.05);
447  border-radius: 4px;
448  font-size: 0.9rem;
449  animation: slideIn 0.3s ease;
450}
451
452@keyframes slideIn {
453  from {
454    opacity: 0;
455    transform: translateX(-10px);
456  }
457  to {
458    opacity: 1;
459    transform: translateX(0);
460  }
461}
462
463.log-time {
464  opacity: 0.7;
465}
466
467.no-logs {
468  text-align: center;
469  padding: 30px;
470  color: #666;
471  font-style: italic;
472}
473
474.info-box {
475  background: rgba(0, 255, 136, 0.1);
476  border: 2px solid #00ff88;
477  border-radius: 10px;
478  padding: 15px;
479  text-align: center;
480  color: #00ff88;
481  line-height: 1.6;
482}
483
484code {
485  background: rgba(0, 0, 0, 0.3);
486  padding: 2px 8px;
487  border-radius: 3px;
488  color: #00b4d8;
489}
490</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. Comment arrête-t-on un watcher dans Vue.js ?

  2. 2. Comment effectue-t-on le cleanup (libération des ressources) dans un watcher ?

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

Tâches pratiques dans le jeu

  • Tri vertical

    Ordonne le cycle de vie d'un watcher, de la création à l'arrêt :

  • Éditeur de code

    Complète le code - enregistre la fonction stop et utilise onCleanup pour annuler la requête précédente.

  • Clique dans l'ordre

    Ordonne watch avec onCleanup dans le bon ordre :

  • Tri horizontal

    Ordonne les opérations du watcher dans l'ordre : création, pause, reprise, arrêt :

  • Tri vertical

    Ordonne les étapes d'arrêt d'un watcher et de nettoyage des ressources :

  • Éditeur de code

    Complète le watch qui s'arrête lui-même une fois une condition remplie (ex. les données sont complètes).