Kurs Vue.js · Moduł 4: Computed i watch
Watch - System Monitorowania
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. Do czego służy watch() we Vue.js?
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.