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

watchEffect - Automatyczne Śledzenie

4 min czytania
W tej lekcji4

Dyżurny inżynier dopisał do komunikatu watchera trzecią wartość, poziom baterii, ale zapomniał dodać ją do tablicy źródeł. Przez tydzień dziennik pokazywał nieaktualne stany baterii, bo watcher budził się tylko przy zmianie dwóch pierwszych parametrów. Przy watch lista źródeł i treść callbacku żyją osobno, więc łatwo je rozsynchronizować. watchEffect usuwa ten problem: piszesz tylko efekt, a Vue sam ustala, z jakich danych on korzysta.

Podstawy watchEffect

watchEffect przyjmuje jedną funkcję, nazywaną efektem. Vue uruchamia ją od razu, w chwili tworzenia watchera, i podczas tego uruchomienia zapisuje każdą reaktywną wartość, którą efekt odczytał. Te wartości stają się zależnościami, a zmiana dowolnej z nich uruchamia efekt ponownie.

1<script setup>
2import { ref, watchEffect } from 'vue'
3
4const count = ref(0)
5const message = ref('Hello')
6
7// Automatycznie śledzi count i message
8watchEffect(() => {
9  console.log(`Count: ${count.value}, Message: ${message.value}`)
10})
11
12// Wywoła się:
13// 1. Natychmiast przy inicjalizacji
14// 2. Gdy count się zmieni
15// 3. Gdy message się zmieni
16</script>

Konsola pokaże pierwszy wpis, zanim cokolwiek zmienisz, a kolejne po każdej zmianie count lub message. Zależnościami są jednak tylko wartości odczytane podczas ostatniego przebiegu: jeśli efekt czyta coś wyłącznie w gałęzi if, która się nie wykonała, zmiana tej wartości go nie obudzi. Uważaj też na efekty asynchroniczne, bo śledzone są tylko odczyty wykonane przed pierwszym await. Watcher utworzony w <script setup> zatrzyma się sam przy odmontowaniu komponentu, a szczegóły zatrzymywania poznasz w następnej lekcji.

watch vs watchEffect

Oba narzędzia potrafią wykonać tę samą pracę, różnią się sposobem działania. Porównaj je na jednym liczniku, obserwowanym na dwa sposoby jednocześnie.

1import { ref, watch, watchEffect } from 'vue'
2
3const count = ref(0)
4
5// watch - jawne źródło, dostęp do oldValue
6watch(count, (newVal, oldVal) => {
7  console.log(`${oldVal} → ${newVal}`)
8})
9
10// watchEffect - automatyczne śledzenie, brak oldValue
11watchEffect(() => {
12  console.log(`Count: ${count.value}`)
13})

watch dostaje źródło jawnie i podaje starą wartość, więc po zmianie licznika wypisze 0 → 1. watchEffect wypisze samo Count: 0 od razu przy starcie, a potem Count: 1, bo starej wartości nie zna - widzi tylko stan bieżący. Różnice zebrane w tabeli:

CechawatchwatchEffect
ŹródłoJawneAutomatyczne
oldValueTakNie
immediateOpcjonalneZawsze
LazyTakNie

Wiersz "Lazy" oznacza, że watch domyślnie czeka na pierwszą zmianę, a watchEffect rusza od razu. Które wybrać? Polecam watch w kodzie, który będą czytać inni: źródło widać w pierwszej linijce, jak w specyfikacji czujnika. Po watchEffect sięgaj wtedy, gdy efekt korzysta z kilku wartości, każda z nich ma go wywołać, a stara wartość nie jest potrzebna.

Cleanup w watchEffect

Efekty często uruchamiają pracę, która trwa: zapytanie do serwera, interwał, połączenie. Gdy id zmieni się, zanim poprzednie zapytanie wróci, spóźniona odpowiedź może nadpisać nową. Dlatego efekt dostaje jako pierwszy argument funkcję onCleanup. Rejestrujesz w niej sprzątanie, a Vue wywoła je przed kolejnym uruchomieniem efektu i przy zatrzymaniu watchera. Do anulowania zapytania służy AbortController: jego pole signal przekazujesz do fetch, a metoda abort() przerywa trwające żądanie.

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 - wywoływane przed każdym ponownym uruchomieniem
16  onCleanup(() => {
17    controller.abort()
18  })
19})
20</script>

Gdy id zmieni się z 1 na 2, Vue najpierw wywoła sprzątanie i przerwie zapytanie o numer 1, a dopiero potem uruchomi efekt dla numeru 2. Sprzątanie nie pomija żadnego przebiegu - efekt nadal reaguje na każdą zmianę. Przerwany fetch odrzuca obietnicę błędem AbortError, więc w prawdziwym panelu dopisz obsługę tego jednego błędu. Pełny wzorzec zobaczysz w następnej lekcji. Od Vue 3.5 istnieje też importowana funkcja onWatcherCleanup(), ale trzeba ją wywołać synchronicznie, przed pierwszym await.

watchPostEffect i watchSyncEffect

Domyślnie watchEffect działa w trybie flush: 'pre', czyli wykonuje efekt przed aktualizacją DOM. Gdy potrzebujesz odczytać już odświeżony DOM, na przykład zmierzyć wysokość panelu po dodaniu wiersza, użyj watchPostEffect, czyli watchEffect z flush: 'post'. watchSyncEffect to wariant z flush: 'sync', uruchamiany synchronicznie przy każdej pojedynczej zmianie.

1import { watchEffect, watchPostEffect, watchSyncEffect } from 'vue'
2
3// Domyślnie - przed aktualizacją DOM
4watchEffect(() => { /* ... */ })
5
6// Po aktualizacji DOM
7watchPostEffect(() => {
8  // Tutaj DOM jest już zaktualizowany
9})
10
11// Synchronicznie - natychmiast przy zmianie
12watchSyncEffect(() => {
13  // Uwaga: może wpływać na wydajność
14})

Kolejność od najwcześniejszego do najpóźniejszego jest stała: sync natychmiast przy zmianie, pre przed aktualizacją DOM, post po niej. Wariant synchroniczny nie grupuje zmian, więc trzy przypisania pod rząd to trzy uruchomienia efektu. Traktuj go jako wyjątek, a nie domyślny wybór.

Pamiętaj: watchEffect to autopilot telemetrii - sam wykrywa, z których czujników korzysta, i startuje od razu, bez czekania na pierwszą zmianę.

Kod do tej lekcji: App.vue
1<script setup>
2import { ref, watchEffect } from 'vue'
3
4// NOVA LAB - Auto-Tracking System with watchEffect()
5const solarPanelAngle = ref(45)
6const sunIntensity = ref(70)
7const batteryCharge = ref(85)
8const powerConsumption = ref(1200)
9
10const logs = ref([])
11
12// watchEffect - automatically tracks ALL reactive dependencies
13watchEffect(() => {
14  // This will re-run whenever ANY dependency changes
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: `Power Generation: ${effectivePower}W | Net: ${netPower > 0 ? '+' : ''}${netPower.toFixed(0)}W`,
25    color: netPower > 0 ? '#00ff88' : '#ff0044'
26  })
27
28  // Auto-adjust battery based on net power
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// Track battery status separately
37watchEffect(() => {
38  if (batteryCharge.value < 20) {
39    const timestamp = new Date().toLocaleTimeString()
40    logs.value.unshift({
41      time: timestamp,
42      message: 'WARNING: Battery critically low!',
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() - Auto-Dependency Tracking</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>Sun Intensity</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>Panel Angle</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>Power Consumption</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>Battery Charge</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">Effective Power:</span>
147        <span class="stat-value power">{{ effectivePower }}W</span>
148      </div>
149      <div class="stat">
150        <span class="stat-label">Net Power:</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">Optimize Panels</button>
162      <button @click="nightMode" class="night">Night Mode</button>
163      <button @click="dayMode" class="day">Day Mode</button>
164    </div>
165
166    <div class="log-panel">
167      <h3>Auto-Tracking Log</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> Automatically tracks all reactive dependencies!
182      No need to specify what to watch - it figures it out on its own.
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>

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. Jaka jest kluczowa różnica między watch a watchEffect?

  2. 2. Kiedy watchEffect wykonuje się po raz pierwszy?

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

  • Edytor kodu

    Uzupełnij watchEffect automatycznie śledzące zmiany wielu refs i logujące je.

  • Układanie w pionie

    Ułóż cykl życia watchEffect od utworzenia do czyszczenia:

  • Edytor kodu

    Uzupełnij watch obserwujący tablicę wielu źródeł [sensorA, sensorB, sensorC].

Przydatne artykuły