Kurs Vue.js · Moduł 4: Computed i watch

Watch - System Monitorowania

5 min czytania
W tej lekcji4

Computed odpowiada na pytanie "ile wynosi", ale spora część pracy stacji to nie wartości, tylko reakcje. Gdy operator wpisuje nazwę modułu, trzeba odpytać bazę NOVA LAB. Gdy temperatura reaktora przekroczy normę, trzeba podnieść alarm i zapisać zdarzenie w dzienniku. Takich efektów ubocznych nie wolno wkładać do computed, bo jego getter ma tylko liczyć i od razu zwracać wynik. Do reagowania na zmiany służy watcher, czyli czujnik alarmowy Centrum Kontroli.

Podstawowy watch()

Funkcja watch przyjmuje dwa argumenty i opcjonalnie trzeci. Pierwszy to źródło, czyli to, co obserwujemy: ref, obiekt z reactive, getter albo tablica takich źródeł. Drugi to callback, który Vue wywoła po każdej zmianie źródła, przekazując mu nową i poprzednią wartość. Watcher jest domyślnie leniwy: przy utworzeniu nic nie robi i czeka na pierwszą zmianę. Poniżej wyszukiwarka modułów, w której callback jest funkcją asynchroniczną, a opóźnienie odpowiedzi bazy udaje await na obietnicy rozwiązywanej przez setTimeout.

1<template>
2  <div class="module-search" style="background: #0a0e27; color: #00ff88;">
3    <input v-model="searchQuery" placeholder="Szukaj modułu..." style="background: #1a1e37; color: #00ff88;" />
4    <p v-if="isSearching" style="color: #00b4d8;">Wyszukiwanie...</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 na pojedynczą wartość
21watch(searchQuery, async (newQuery, oldQuery) => {
22  console.log(`Search changed: "${oldQuery}" → "${newQuery}"`)
23
24  if (newQuery.length < 2) {
25    searchResults.value = []
26    return
27  }
28
29  isSearching.value = true
30
31  // Symulacja API call do bazy 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>

Wpisz "ox", a w konsoli pojawi się linia ze starym i nowym zapytaniem, a po pół sekundy wynik wyszukiwania. Zapytania krótsze niż dwa znaki czyszczą listę i kończą callback instrukcją return, zanim cokolwiek zostanie wysłane. Co się nie zmieniło: searchQuery nadal jest zwykłym ref podpiętym przez v-model, a watcher niczego w nim nie zapisuje, tylko nasłuchuje. Zwróć też uwagę na moment reakcji. Vue nie wywołuje callbacku w tej samej linijce, w której zmieniasz wartość: wychwytuje zmianę i obsługuje ją w następnym ticku, przed odświeżeniem DOM. Dwie zmiany w jednym ticku dają więc jedno wywołanie, już z najnowszą wartością.

Opcje Watch

Trzeci argument to obiekt opcji. immediate: true każe wykonać callback od razu przy tworzeniu watchera, ze starą wartością równą undefined, co przydaje się do pobrania danych na starcie panelu. deep: true włącza głębokie obserwowanie zagnieżdżonych obiektów. flush wybiera moment wywołania: 'pre' (domyślnie, przed aktualizacją DOM), 'post' (po aktualizacji, gdy chcesz czytać odświeżony DOM) albo 'sync' (natychmiast przy każdej zmianie). once: true, dostępne od Vue 3.4, zatrzymuje watcher po pierwszym wywołaniu.

1import { ref, watch } from 'vue'
2
3const count = ref(0)
4
5watch(count, (newValue) => {
6  console.log('Count:', newValue)
7}, {
8  immediate: true,  // Wywołaj natychmiast przy inicjalizacji
9  deep: true,       // Głębokie obserwowanie obiektów
10  flush: 'post',    // Kiedy wykonać: 'pre', 'post', 'sync'
11  once: true        // Tylko raz (Vue 3.4+)
12})

Ten blok to ściąga, a nie wzór do skopiowania. count jest liczbą, więc deep niczego tu nie zmienia, a połączenie immediate z once oznacza, że callback wykona się raz, od razu przy tworzeniu, i watcher natychmiast się wyłączy. W praktyce konfiguracja idzie zawsze w tej samej kolejności: import watch z vue, źródło, callback, a na końcu tylko te opcje, których naprawdę potrzebujesz.

Watch na Wiele Źródeł

Systemy podtrzymywania życia ocenia się razem: tlen i temperaturę. Zamiast dwóch watcherów podajesz tablicę źródeł. Callback dostaje wtedy tablicę nowych wartości i tablicę starych, w tej samej kolejności co źródła, a destrukturyzacja [newO2, newTemp] od razu nadaje im nazwy.

1import { ref, watch } from 'vue'
2
3const oxygenLevel = ref(95)
4const temperature = ref(22)
5
6// Tablica źródeł - monitorowanie wielu parametrów
7watch([oxygenLevel, temperature], ([newO2, newTemp], [oldO2, oldTemp]) => {
8  console.log(`Life support changed:
9    O2: ${oldO2}% → ${newO2}%
10    Temp: ${oldTemp}°C → ${newTemp}°C`)
11})

Callback uruchomi się, gdy zmieni się którekolwiek ze źródeł. Jeśli zmienił się tylko tlen, newTemp i oldTemp będą równe. Watcher nie mówi, które źródło ruszyło, więc w razie potrzeby porównaj pary samodzielnie.

Watch na Getter

Obiekt z reactive wymaga ostrożności. Zapis watch(reactor.temperature, callback) nie zadziała, bo przekazujesz w ten sposób liczbę 2500, a nie reaktywne źródło, i Vue ostrzeże o nieprawidłowym źródle. Właściwość obiektu obserwujesz przez getter, czyli funkcję () => reactor.temperature, którą Vue wywołuje, żeby odczytać aktualną wartość.

1import { reactive, watch } from 'vue'
2
3const reactor = reactive({
4  name: 'Plasma Core Alpha',
5  temperature: 2500
6})
7
8// Watch na getter (funkcję)
9watch(
10  () => reactor.temperature,
11  (newTemp) => {
12    console.log('Nowa temperatura reaktora:', newTemp)
13    if (newTemp > 5000) {
14      console.warn('CRITICAL: Temperature exceeds safe limits!')
15    }
16  }
17)
18
19// Watch na głęboki obiekt
20watch(
21  () => reactor,
22  (newReactor) => {
23    console.log('Reactor config changed:', newReactor)
24  },
25  { deep: true }
26)

Pierwszy watcher reaguje wyłącznie na temperaturę - zmiana nazwy reaktora go nie obudzi. Drugi obserwuje getter zwracający cały obiekt, dlatego potrzebuje { deep: true }. Bez tej opcji nie zareagowałby na zmianę żadnego pola, bo getter bez deep budzi watcher tylko wtedy, gdy zwraca inną wartość niż poprzednio, a tu zwraca stale ten sam obiekt. Obiekt z reactive przekazany bezpośrednio, bez gettera, tworzy watcher głęboki automatycznie. Z ref przechowującym obiekt jest odwrotnie: zmiana zagnieżdżonego pola, np. missionData.value.systems.oxygen, bez deep: true przejdzie niezauważona. Przy takiej zmianie w miejscu nowa i stara wartość wskazują ten sam obiekt, więc ich porównanie niczego nie powie.

Pamiętaj o koszcie: deep przy każdym uruchomieniu przechodzi po wszystkich zagnieżdżonych polach, co na dużych strukturach bywa kosztowne. Od Vue 3.5 możesz ograniczyć głębokość liczbą, np. deep: 2. Polecam jednak najpierw sięgnąć po getter wskazujący konkretne pole - czujnik podpięty do jednego przewodu jest tańszy niż skaner całego modułu. W następnej lekcji poznasz watchEffect, który sam odnajduje swoje źródła.

Pamiętaj: watch to czujnik alarmowy - wskazujesz mu źródło, a on przy każdej zmianie podaje Ci nowy i poprzedni odczyt.

Kod do tej lekcji: App.vue
1<script setup>
2import { ref, watch } from 'vue'
3
4// NOVA LAB - System Monitoring with watch()
5const oxygenLevel = ref(95)
6const powerLevel = ref(2800)
7const waterLevel = ref(85)
8const logs = ref([])
9
10// watch() - reacts to specific source changes
11watch(oxygenLevel, (newValue, oldValue) => {
12  const timestamp = new Date().toLocaleTimeString()
13  logs.value.unshift({
14    time: timestamp,
15    type: 'oxygen',
16    message: `Oxygen: ${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: 'WARNING: Low oxygen level detected!',
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: `Power: ${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: 'CRITICAL: Power below minimum threshold!',
44      color: '#ff0044'
45    })
46  }
47})
48
49// Watch multiple 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: 'All systems optimal!',
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">watch() API - Reactive Monitoring</p>
97    </div>
98
99    <div class="controls-panel">
100      <div class="system-control">
101        <h3>Oxygen System</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>Power System</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>Water Reserves</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">Simulate Emergency</button>
154      <button @click="restoreNominal" class="success">Restore Nominal</button>
155      <button @click="clearLogs" class="neutral">Clear Logs</button>
156    </div>
157
158    <div class="log-panel">
159      <h3>System Event Log</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          No events logged yet. Adjust system parameters to see watch() in action!
172        </div>
173      </div>
174    </div>
175
176    <div class="info-box">
177      <strong>watch() API:</strong> Monitors specific reactive sources and executes callbacks when they change.
178      Perfect for logging, side effects, and conditional responses!
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>

Widzisz błąd w tej lekcji?

Sprawdź się

Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.

  1. 1. Do czego służy watch() we Vue.js?

  2. 2. Jakie parametry otrzymuje callback w watch()?

To 2 z 4 pytań do tej lekcji. Pozostałe rozwiążesz w grze.

Zadania praktyczne w grze

  • Klikanie w kolejności

    Ułóż poprawną składnię watch:

  • Edytor kodu

    Uzupełnij watch obserwujący searchQuery i logujący zmiany z nową i starą wartością.

  • Układanie w pionie

    Ułóż kroki konfiguracji watch w prawidłowej kolejności:

  • Edytor kodu

    Uzupełnij watch z opcją immediate: true do pobrania danych natychmiast po utworzeniu watchera.

  • Układanie w pionie

    Ułóż etapy działania watch od zmiany źródła do callbacku:

  • Układanie w poziomie

    Ułóż watch z opcją deep w prawidłowej kolejności:

  • Edytor kodu

    Uzupełnij watch z { deep: true } do obserwowania zmian w zagnieżdżonym reactive obiekcie.

Przydatne artykuły