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, @name. 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ąć.
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.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.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.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, @name. 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.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.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.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ć.