Kurs Vue.js · Moduł 4: Computed i watch
Zatrzymywanie watcherów - zamykanie kanałów telemetrii
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. Jak zatrzymać watcher we Vue.js?
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).