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

Zatrzymywanie watcherów - zamykanie kanałów telemetrii

13 min czytania
W tej lekcji7

Nocna zmiana w Centrum Obliczeniowym NOVA LAB zostawiła zgłoszenie. Operator zamknął panel sondy Ares-3, przeszedł do panelu reaktora, a dziennik konsoli dalej zapełniał się odczytami ciśnienia z sondy, na którą nikt już nie patrzy. Nikt tego kodu nie wywołuje, a on i tak pracuje.

Tak właśnie zachowuje się watcher. To nie jest funkcja, którą uruchamiasz raz i o której możesz zapomnieć - to otwarty kanał nasłuchu, podpięty do reaktywnego źródła. Dopóki ktoś go nie zamknie, kanał zajmuje pamięć, wykonuje swój callback przy każdej zmianie i potrafi nadpisać dane w panelu przestrojonym dawno temu na inną częstotliwość. Dr. Nova ujmuje to jednym zdaniem: każdy kanał otwarty w Centrum Kontroli musi mieć wyłącznik. W tej lekcji poznasz ten wyłącznik, dowiesz się, kiedy Vue przekręca go za Ciebie, a kiedy musisz zrobić to sam, i nauczysz się sprzątać po robocie, którą watcher zdążył zacząć.

Cykl życia watchera

Zanim sięgniesz po wyłącznik, ustawmy w głowie kolejność zdarzeń, bo bez niej trudno zrozumieć, co właściwie wyłączasz. Watcher przechodzi przez cztery etapy i zawsze w tej samej kolejności.

Etap pierwszy to utworzenie watchera - wywołujesz watch() albo watchEffect(). Etap drugi to rejestracja zależności i nasłuchiwanie zmian: Vue zapamiętuje, które reaktywne źródła zostały odczytane, i podpina się pod nie, tak jak dyżurny wpisuje częstotliwość sondy do konsoli. Etap trzeci to wykonanie callbacku przy zmianie - dopóki kanał jest otwarty, każda zmiana źródła uruchamia Twoją funkcję. Etap czwarty to zatrzymanie, czyli wywołanie stop() albo odmontowanie komponentu.

Etapy drugi i trzeci powtarzają się dowolnie wiele razy, przez cały czas życia panelu. Etap czwarty następuje dokładnie raz i jest nieodwracalny: zatrzymanego watchera nie da się z powrotem włączyć, bo nie ma czegoś takiego jak start(). Jeśli kanał ma znów nadawać, trzeba go otworzyć od nowa, tworząc nowy watcher. Wyjątkiem jest pauza, którą poznasz na końcu lekcji, ale pauza to co innego niż zatrzymanie.

Wyłącznik, który dostajesz w zamian

Skąd wziąć ten wyłącznik? Nie trzeba go szukać - Vue oddaje go w chwili tworzenia kanału. Wywołanie watch() zwraca funkcję, a wywołanie watchEffect() również zwraca funkcję. Nie jest to obiekt obserwatora ani obserwowana wartość, tylko zwykła funkcja bez argumentów, którą przyjęło się nazywać stop function. Dopóki nie zapiszesz jej w zmiennej, wyłącznik wpada w próżnię i kanału nie zamkniesz niczym innym. Poniżej minimalny panel sondy z licznikiem siły sygnału i przyciskiem, który ten kanał zamyka.

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 returns a stop function - keep it in a variable
13const stopChannel = watch(signalStrength, (value) => {
14  console.log('Signal:', value)
15})
16</script>

Klikaj pierwszy przycisk i patrz w konsolę: przy każdym kliknięciu pojawia się nowa linia. Kliknij drugi przycisk i klikaj pierwszy dalej - konsola milczy. Teraz najważniejsze, czyli to, co się nie zmieniło. Licznik signalStrength nadal jest reaktywny, akapit w szablonie nadal pokazuje aktualną liczbę, a przycisk nadal ją zwiększa. Zatrzymanie watchera nie unieważnia danych ani nie zamraża widoku - gaśnie wyłącznie nasłuch. W tym panelu to jedyna rzecz, którą stopChannel() robi, i jednocześnie jedyny sposób, w jaki watcher da się wyłączyć ręcznie.

Warto od razu wyprostować trzy nieporozumienia, bo krążą po zespołach przechodzących na Vue z innych narzędzi. Po pierwsze, nie istnieje żadna metoda watcher.destroy() - watch() nie zwraca obiektu, na którym można by cokolwiek wywołać, tylko funkcję, więc nie ma tam pola do odwołania się kropką. Po drugie, nieprawdą jest, że watchera nie da się zatrzymać ręcznie - właśnie to zrobiłeś jedną linijką. Po trzecie, restart komponentu owszem usuwa jego watchery i za chwilę to wykorzystamy, ale wymuszanie przeładowania całego panelu po to, żeby uciszyć jeden kanał, przypomina odcięcie zasilania modułu w celu zgaszenia jednej kontrolki: zadziała i przy okazji zniszczy cały stan panelu.

Vue zamyka kanały za Ciebie

Zanim zaczniesz odruchowo zapisywać uchwyt przy każdym watcherze, dobra wiadomość: w większości przypadków nie musisz. Watcher utworzony synchronicznie w <script setup> zostaje przypisany do instancji komponentu, w której powstał. Gdy komponent jest odmontowywany, czyli znika ze strony (unmount), Vue zatrzymuje wszystkie należące do niego watchery. Panel sondy gaśnie razem ze swoimi kanałami i żaden z nich nie zostaje otwarty w tle.

Zapamiętaj ten moment dokładnie, bo łatwo o pomyłkę. Watchery nie zatrzymują się po pierwszym wywołaniu callbacku - takie zachowanie musisz zamówić osobno opcją once: true, dostępną od Vue 3.4, którą widziałeś przy opcjach watch. Nie zatrzymują się też po czasie bezczynności - Vue niczego nie odlicza i nie ma żadnego limitu minuty ani innego zegara wygaszającego. I na pewno nie jest tak, że trzeba je zawsze zatrzymywać ręcznie, bo wtedy każdy nieuważny komponent zostawiałby po sobie wyciek pamięci.

Jest jednak wyjątek i to on odpowiada za zgłoszenie z nocnej zmiany. Przypisanie do komponentu działa tylko dla watcherów tworzonych synchronicznie, czyli w chwili, gdy Vue wykonuje kod <script setup>. Watcher powołany później - w setTimeout, w odpowiedzi na zdarzenie albo po await w funkcji asynchronicznej - powstaje już poza tym oknem i nie ma właściciela. Poniżej dwa kanały o identycznej treści, różniące się wyłącznie momentem otwarcia.

1<script setup>
2import { ref, watchEffect } from 'vue'
3
4const pressure = ref(1013)
5
6// created synchronously in setup - owned by this component
7watchEffect(() => {
8  console.log('Pressure:', pressure.value)
9})
10
11setTimeout(() => {
12  // created one second later - nobody owns this one
13  watchEffect(() => {
14    console.log('Delayed pressure:', pressure.value)
15  })
16}, 1000)
17</script>

Oba watchery są prawdziwymi watcherami: oba śledzą pressure tak samo, oba wykonają swój callback natychmiast po utworzeniu i przy każdej kolejnej zmianie. Różnica ujawnia się dopiero przy zamknięciu panelu. Pierwszy kanał gaśnie razem z komponentem. Drugi nadaje dalej, trzymając przy okazji przy życiu wszystko, do czego się odwołuje - to jest właśnie wyciek pamięci i dokładnie ta linia w dzienniku, której dyżurny nie potrafił wyjaśnić.

Skoro Vue nie zamknie takiego kanału, musimy zrobić to sami. Uchwyt zwrócony przez watchEffect() trzeba przechować w zmiennej widocznej poza funkcją zwrotną, a samo wyłączenie podpiąć pod hook onUnmounted, czyli funkcję z rodziny hooków cyklu życia, którą Vue wywołuje tuż po usunięciu komponentu ze strony.

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>

Zmienna stopDelayed jest deklarowana przez let, a nie przez const, bo uchwyt wpada do niej dopiero po odliczeniu sekundy. Z tego samego powodu potrzebny jest warunek: gdy operator zamknie panel wcześniej, niż zadziała setTimeout, zmienna wciąż trzyma null i wywołanie jej jako funkcji rzuciłoby błąd. Poza tym w tym bloku nie zmieniło się nic - watcher śledzi te same dane i wykonuje ten sam callback, doszedł wyłącznie plan awaryjny na wypadek zniknięcia panelu.

Przy okazji rozbrójmy wzorzec, który spotkasz w starszym kodzie: tworzenie zwykłego watchera w onMounted i ręczne zatrzymywanie go w onUnmounted. Hook onMounted wykonuje się przy aktywnym komponencie, więc taki watcher w bieżących wersjach Vue i tak dostaje właściciela i zostaje zatrzymany automatycznie. Ręczne wywołanie stop() niczego tam nie psuje, bo powtórne zatrzymanie już zatrzymanego watchera nic nie robi, ale też niczego nie ratuje. Przed wyciekiem chroni Cię zapamiętanie uchwytu dla kanałów otwieranych asynchronicznie, a nie sam fakt sprzątania w onUnmounted.

Watcher, który wyłącza się sam

Jest jeszcze trzecia sytuacja, obok wyłącznika pod przyciskiem i sprzątania przy odmontowaniu. Czasem kanał ma z góry określone zadanie: czekamy na jeden konkretny stan i po jego wykryciu nasłuch przestaje mieć sens. Stacja odbiera pakiety telemetrii z łazika, a nas interesuje wyłącznie moment, w którym pakiet zostanie skompletowany. Watcher może wtedy zamknąć się własnym uchwytem, wywołując go z wnętrza swojego callbacku.

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>

Wygląda to jak wąż gryzący własny ogon, ale jest całkowicie bezpieczne. W chwili wywołania watch() callback jeszcze się nie wykonuje - Vue tylko rejestruje zależności. Zanim dojdzie do pierwszej zmiany telemetry, stała stopWhenComplete jest już zainicjalizowana i wskazuje na gotowy uchwyt. Warunek w środku sprawdza dwie rzeczy naraz: czy pakiet w ogóle przyszedł, bo początkową wartością jest null, oraz czy ma ustawioną flagę complete. Po zamknięciu kanału telemetry nadal można podmieniać dowolnie wiele razy - reaktywność działa, po prostu nikt jej już nie słucha.

Jedna pułapka, której lepiej nie odkrywać samodzielnie w produkcyjnym panelu. Gdy dopiszesz do takiego watchera opcję immediate: true, callback wykona się natychmiast, jeszcze w trakcie wywołania watch(), czyli zanim stała zdąży dostać wartość - odwołanie do stopWhenComplete rzuci wtedy błąd o użyciu zmiennej przed inicjalizacją. Jeśli watcher ma zareagować dokładnie raz i zniknąć, polecam zamiast samozatrzymywania użyć opcji once: true z Vue 3.4: intencja jest widoczna od razu w opcjach, a Vue samo zamyka kanał po pierwszym wywołaniu. Ręczne samozatrzymanie zostaw do przypadków z warunkiem, tak jak flaga complete powyżej.

onCleanup, czyli sprzątanie przerwanej roboty

Do tej pory zamykaliśmy kanał, który nic po sobie nie zostawiał. W praktyce watchery uruchamiają robotę trwającą w czasie: pobieranie danych z serwera, odliczanie interwału, nasłuch WebSocketu. Wyobraź sobie panel, który przy każdej zmianie numeru sondy startuje odpytywanie co sekundę. Operator przeskakuje przez trzy sondy, callback wykonuje się trzy razy i po chwili w tle tykają trzy niezależne interwały, choć oglądany jest tylko jeden panel.

Vue daje na to jeden mechanizm i jest on wbudowany w sam callback. Funkcja przekazywana do watch() może przyjąć trzeci argument, nazywany zwyczajowo onCleanup. To funkcja, którą wywołujesz wewnątrz callbacku, podając jej własną funkcję sprzątającą. Vue zapamięta ją i wywoła w dwóch momentach: tuż przed kolejnym uruchomieniem callbacku oraz w chwili zatrzymania watchera. Kolejność argumentów jest sztywna, więc onCleanup zawsze stoi na trzeciej pozycji, po nowej i starej wartości.

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>

Przełącz sondę kilka razy i policz linie w konsoli: w danej chwili odpytywana jest zawsze dokładnie jedna sonda, bo przed każdym nowym uruchomieniem Vue kasuje poprzedni interwał. Zauważ, czego onCleanup nie robi. Nie pomija żadnego uruchomienia callbacku - watcher reaguje na każdą zmianę tak samo jak wcześniej. Nie zatrzymuje też watchera, bo sprzątanie i zamykanie kanału to dwie różne operacje. Zwróć również uwagę na kolejność: Vue uruchamia funkcję sprzątającą, zanim callback ruszy po raz kolejny, więc stary interwał gaśnie, zanim powstanie nowy. Sprzątanie sięga przy tym po timer z tego samego przebiegu, w którym zostało zarejestrowane - każde wywołanie callbacku ma swój komplet zmiennych i sprząta wyłącznie po sobie.

Skoro sprzątanie ma tylko jedną poprawną formę, wyliczmy pomysły, które nie zadziałają. Zwrócenie funkcji sprzątającej z callbacku, jak w useEffect znanym z Reacta, jest w Vue martwym kodem - wartość zwracana przez callback watchera jest ignorowana, a przy callbacku asynchronicznym i tak byłaby to obietnica, nie funkcja. Nie ma również żadnej globalnej funkcji useCleanup() - przedrostek use w ekosystemie Vue oznacza composable, czyli funkcję pisaną przez Ciebie, a nie element API frameworka. Nieprawdą jest wreszcie, że watch nie wspiera sprzątania: wspiera je dokładnie tak samo jak watchEffect, z jedną różnicą w miejscu argumentu. W watchEffect, które nie dostaje nowej ani starej wartości, onCleanup jest argumentem pierwszym i tak właśnie wyglądał przykład z AbortController w poprzedniej lekcji. W watch jest trzeci.

Uchwyt i sprzątanie w jednym panelu

Połączmy oba narzędzia, bo w prawdziwym panelu misji występują razem. Kanał synchronizacji pobiera odczyt wybranej sondy z serwera, operator ma przycisk do wyłączenia synchronizacji, a każde przełączenie sondy musi anulować zapytanie, które jeszcze nie wróciło. Do anulowania posłuży AbortController - przeglądarkowy obiekt, który wystawia pole signal przekazywane do fetch oraz metodę abort() przerywającą trwające zapytanie. Przerwany fetch odrzuca obietnicę błędem o nazwie AbortError, więc ten jeden przypadek wyciszamy, bo nie jest awarią, tylko potwierdzeniem, że sprzątanie zadziałało.

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>

To jest wzorzec, który warto mieć w palcach: najpierw watch(source,, potem callback z trójką argumentów (newVal, oldVal, onCleanup) => {, w pierwszej linii ciała rejestracja sprzątania onCleanup(() => controller.abort()), a na końcu domknięcie }). Rejestrację sprzątania stawiaj od razu po utworzeniu kontrolera, przed pierwszym await - inaczej szybkie przełączenie sondy trafi w okno, w którym zapytanie już leci, a Vue nie wie jeszcze, jak je przerwać. Argument oldId zostaje w podpisie, mimo że go nie używamy, bo pozycji w liście argumentów nie da się przeskoczyć.

Zwróć teraz uwagę na najciekawszy efekt tego połączenia. Kliknięcie przycisku wyłączającego wywołuje stopSync(), a Vue przy zatrzymywaniu watchera uruchamia ostatnią zarejestrowaną funkcję sprzątającą - trwające zapytanie zostaje anulowane, choć nikt nie prosił o to osobno. Zamknięcie kanału zabiera więc ze sobą robotę, którą kanał zdążył zlecić. Nie zmienia natomiast wartości reading: dane pobrane wcześniej zostają w panelu, a jedyne, co znika, to gotowość do reagowania na kolejne przełączenia sondy.

Pauza zamiast wyłącznika (Vue 3.5+)

Na koniec sytuacja, w której stop() jest zbyt ostatecznym narzędziem. Podczas burzy pyłowej czujnik zapylenia szaleje i zalewa dziennik setkami wpisów. Nie chcemy zamykać kanału, bo za dwadzieścia minut znów będzie potrzebny, a odtwarzanie watchera oznaczałoby powielanie tej samej konfiguracji w dwóch miejscach kodu. Chcemy go wyciszyć. Od Vue 3.5 uchwyt zwracany przez watch() i watchEffect() jest wprawdzie dalej funkcją, którą można wywołać jak dotychczas, ale ma dodatkowo trzy metody: stop, pause i resume. Dzięki temu wolno go rozpakować przez destrukturyzację i sięgnąć po każdą z nich osobno.

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// mute the channel during the storm
10pause()
11
12// bring it back
13resume()
14
15// close it for good
16stop()

Kolejność w tym bloku nie jest przypadkowa i warto ją zapamiętać: najpierw utworzenie z destrukturyzacją, potem pauza, potem wznowienie, a zatrzymanie zawsze na końcu, bo po nim żadna z pozostałych metod nie ma już czego obsłużyć. Po pause() watcher istnieje dalej i nadal śledzi dustLevel - zawieszone jest wyłącznie wykonywanie callbacku. Jeśli w czasie pauzy źródło się zmieniło, wywołanie resume() uruchomi callback jeden raz, z aktualną wartością, więc panel nadrobi zaległość zamiast zignorować całą burzę.

Dwie uwagi praktyczne. W wersjach Vue starszych niż 3.5 tych metod po prostu nie ma i jedyne, co dostajesz, to funkcja zatrzymująca - wyciszenie realizuje się wtedy zwykłą flagą sprawdzaną na początku callbacku. I nawet gdy metody są dostępne, pause() nie zastępuje stop(): wstrzymany watcher wciąż zajmuje pamięć i wciąż jest podpięty do źródeł, więc kanał, do którego nie zamierzasz wracać, zamykaj na dobre.

Pamiętaj: watcher to otwarty kanał telemetrii Centrum Kontroli - Vue gasi go razem z panelem, ale kanał otwarty poza kolejnością zamkniesz tylko uchwytem, który sam zdążyłeś zapisać.

Kod do tej lekcji: App.vue
1<script setup>
2import { ref, watch } from 'vue'
3
4// NOVA LAB - Deep Watching Complex Mission Data
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 - detects changes in nested objects
40watch(
41  missionData,
42  (newVal, oldVal) => {
43    const timestamp = new Date().toLocaleTimeString()
44    changeLog.value.unshift({
45      time: timestamp,
46      message: 'Mission data updated (deep change detected)',
47      color: '#00b4d8'
48    })
49  },
50  { deep: true } // Enable deep watching
51)
52
53// Watch specific nested property
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 with immediate option
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: 'Battery critically low!',
75        color: '#ff0044'
76      })
77    }
78  },
79  { immediate: true } // Run immediately on mount
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">Deep Watching & Immediate Execution</p>
127    </div>
128
129    <div class="data-grid">
130      <div class="data-section crew">
131        <h3>Crew Roster</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>Life Support</h3>
142        <div class="system-item">
143          <span>Oxygen:</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>Temperature:</span>
152          <span class="value">{{ missionData.systems.life_support.temperature }}°C</span>
153        </div>
154        <div class="system-item">
155          <span>Humidity:</span>
156          <span class="value">{{ missionData.systems.life_support.humidity }}%</span>
157        </div>
158      </div>
159
160      <div class="data-section power">
161        <h3>Power Systems</h3>
162        <div class="system-item">
163          <span>Solar:</span>
164          <span class="value">{{ missionData.systems.power.solar }}W</span>
165        </div>
166        <div class="system-item">
167          <span>Battery:</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>Consumption:</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 ? 'ONLINE' : 'OFFLINE' }}
199            </span>
200          </div>
201        </div>
202      </div>
203
204      <div class="data-section inventory">
205        <h3>Inventory</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">Emergency Protocol</button>
219      <button @click="restoreNominal" class="restore">Restore Nominal</button>
220    </div>
221
222    <div class="log-section">
223      <h3>Deep Watch Change Log</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          Modify any nested property to see deep watching in action...
235        </div>
236      </div>
237    </div>
238
239    <div class="info-box">
240      <strong>Deep Watching:</strong> Use <code>{ deep: true }</code> to watch nested object changes.
241      Use <code>{ immediate: true }</code> to run the watcher immediately on mount!
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>

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. Jak zatrzymać watcher we Vue.js?

  2. 2. Jak wykonać cleanup (czyszczenie zasobów) w watcherze?

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

Zadania praktyczne w grze

  • Układanie w pionie

    Ułóż cykl życia watchera od utworzenia do zatrzymania:

  • Edytor kodu

    Uzupełnij kod - zapisz stop function i użyj onCleanup do anulowania poprzedniego requestu.

  • Klikanie w kolejności

    Ułóż watch z onCleanup w prawidłowej kolejności:

  • Układanie w poziomie

    Ułóż operacje na watcherze w kolejności: utworzenie, pauzowanie, wznowienie, zatrzymanie:

  • Układanie w pionie

    Ułóż etapy zatrzymywania watchera i czyszczenia zasobów:

  • Edytor kodu

    Uzupełnij watch, który zatrzymuje się sam po spełnieniu warunku (np. dane kompletne).

Przydatne artykuły