Kurs Angular · Moduł 6: Signals
Sygnały dymne - pierwsza wieża na murach
W tej lekcji6
Panel treningowy z drugiego modułu działał bez zarzutu. Klikałeś przycisk, metoda dopisywała powtórzenia do pola klasy, liczba na ekranie rosła. Zwykłe pole, zwykłe przypisanie, żadnych sztuczek - i właśnie dlatego warto zadać jedno niewygodne pytanie: kto właściwie powiedział Angularowi, że ta liczba się zmieniła?
Nikt. Liczba w polu klasy jest liczbą i niczym więcej. Nie ma ust, nie ma gońca, nie umie nikogo zawiadomić o tym, że jeszcze przed chwilą była dziesiątką, a teraz jest dwunastką. Angular radzi sobie z tym jedyną metodą, jaka mu wtedy zostaje: po każdym kliknięciu, po każdym upływie czasomierza i po każdej odpowiedzi z sieci przechodzi po całym drzewie komponentów i sprawdza po kolei każde wyrażenie wpisane w szablony, porównując wynik z poprzednim. Skoro nikt niczego nie melduje, trzeba obejść wszystkich i zapytać.
W Akademii ten układ ma swoją nazwę i swoją cenę. Przez lata wieści między warowniami roznosił goniec: wyruszał po każdym najmniejszym poruszeniu, obchodził wszystkie zamki po kolei i przy każdym dopytywał, czy aby coś się nie zmieniło. Przy trzech zamkach nikt nie narzekał. Przy stu goniec stał się najwolniejszym ogniwem całego systemu - nie dlatego, że biegł źle, tylko dlatego, że biegał wszędzie, także tam, gdzie od tygodnia nic się nie działo.
Dlatego na murach stanęły wieże sygnałowe. Wieża nie czeka na gońca. Kiedy strażnik zmienia to, co wieża pokazuje, dym idzie w górę natychmiast, a dowiaduje się o tym dokładnie ten, kto na tę wieżę patrzył - i nikt więcej. W Angularze taka wieża nazywa się signal, po polsku sygnał: wartość, która sama wie, że się zmieniła, i sama potrafi powiadomić o tym tych, którzy ją czytali. Sygnały pojawiły się w Angularze 16, ustabilizowały w siedemnastce, a w dziewiętnastce, na której pracujesz, są już podstawowym sposobem trzymania stanu. Wspominałem o nich w pierwszym module, przy przeglądzie tego, czym Angular się dziś wyróżnia - dziś zapalisz pierwszą wieżę własnoręcznie.
Czego się nauczysz
- stawiać sygnał funkcją
signal()i nadawać mu wartość początkową - odczytywać wartość sygnału i rozumieć, dlaczego odczyt zapisuje się z nawiasami
- odróżniać sygnał od zwykłego pola klasy, także po tym, co mówi kompilator
- wydawać rozkaz
set(), czyli ustawiać wartość wprost - czytać sygnały w szablonie komponentu tak samo jak w kodzie klasy
- rozpoznawać zapisy, które wyglądają jak sygnały, a nimi nie są
Zapalenie wieży
Funkcję signal() udostępnia pakiet @angular/core - ten sam, z którego od pierwszego modułu bierzesz dekorator @Component. Podajesz jej jeden argument: wartość, którą wieża ma pokazywać na starcie. Umówmy się na przykład, który przejdzie przez pół tej lekcji. Strażnik przy bramie utrzymuje poziom alarmu: liczbę zaczynającą się od zera, gdy na trakcie panuje spokój, i rosnącą, gdy zwiadowcy przynoszą niepokojące meldunki. Skoro zaczynamy od spokoju, wartością początkową będzie zero.
import { signal } from '@angular/core';
const alertLevel = signal(0);Wieża stoi i pokazuje zero. Zwróć uwagę, czego w tej linii nie ma. Nie ma słowa new, bo signal nie jest klasą, tylko zwykłą funkcją, która buduje sygnał i zwraca go w wyniku. Nie ma dekoratora nad polem, bo sygnał nie jest adnotacją doklejaną do zmiennej. Nie ma wreszcie żadnej deklaracji typu, a mimo to TypeScript już wie, że w tej wieży mieszka liczba - wywnioskował to z zera, które podałeś. Od tej chwili próba wpisania tam napisu skończy się błędem kompilacji, zanim aplikacja w ogóle wystartuje. To ta sama dyscyplina, którą poznałeś w pierwszym module przy kodeksie Bushido kodu, tylko schowana za jednym wywołaniem.
Samo wywołanie zapamiętaj jako cztery kawałki układane zawsze w tej samej kolejności: nazwa signal, nawias otwierający, wartość początkowa, nawias zamykający. Nic przed nazwą, nic pomiędzy nawiasem a wartością. Sygnał liczący od zera to więc dosłownie signal, potem (, potem 0, potem ).
Trzy inne zapisy wyglądają równie sensownie i żaden z nich nie działa - sprawdziłem każdy kompilatorem. Zapis new Signal(0) nie ma prawa się skompilować, bo Signal w pakiecie @angular/core jest nazwą typu, a nie klasą; kompilator odpowiada wprost, że Signal odnosi się wyłącznie do typu, a został użyty jako wartość. Zapis createSignal(0) to nazwa przyniesiona z zupełnie innego frameworka, bo @angular/core takiej funkcji nie eksportuje - kompilator mówi, że moduł nie ma takiego eksportu, a sprawdzenie samego pakietu w czasie działania programu potwierdza: nazwy createSignal po prostu tam nie ma. Zapis @Signal() count = 0 przenosi na sygnały nawyk z dekoratorów, które znasz z @Component i @Injectable - tyle że dekoratora o takiej nazwie Angular nie ma, więc kompilator zgłasza błąd niezależnie od tego, czy nazwę Signal w ogóle zaimportujesz. Zostaje jedna droga i to ona jest właściwą odpowiedzią: zwykłe wywołanie funkcji signal().
Dlaczego odczyt ma nawiasy
Wieża stoi, ale nikt na nią jeszcze nie spojrzał. Odczyt sygnału zapisuje się nazwą i pustymi nawiasami, co zaskakuje każdego, kto przywykł do pól klasy: wygląda jak wywołanie metody, choć niczego tu nie liczysz ani nie zmieniasz.
console.log(alertLevel()); // 0Wypisało zero i jest to wynik z uruchomionego kodu, a nie z pamięci. Powód nawiasów jest banalnie prosty i wart zapamiętania na całą resztę modułu: sygnał naprawdę jest funkcją. Nie obiektem z polem w środku, nie zmienną udającą liczbę - funkcją, którą wywołujesz po to, żeby dostać aktualną wartość. To wywołanie robi jednak jeszcze jedną rzecz, niewidoczną gołym okiem. Jeśli wypada w miejscu, które Angular śledzi, a szablon komponentu jest właśnie takim miejscem, odczyt rejestruje zależność: Angular zapisuje sobie, że ten konkretny fragment widoku patrzy na tę konkretną wieżę. Dzięki temu przy najbliższej zmianie nie musi obchodzić wszystkich zamków, bo wie, kogo obudzić. Sprawdziłem to na uruchomionym kodzie: wartość zbudowana na jednym sygnale przeliczyła się dopiero po zmianie tego sygnału, a zapis do sygnału, którego nigdy nie odczytała, nie poruszył jej ani razu. Poza takim śledzonym miejscem odczyt jest najzwyklejszym pobraniem wartości - trzy wywołania pod rząd zwracają tę samą liczbę i niczego nie zużywają.
Skoro sygnał jest funkcją, da się to sprawdzić najprostszym możliwym sposobem. Operator typeof odpowiada jednym słowem, jakiego rodzaju jest wartość: dla liczby powie "number", dla funkcji powie "function". Zadajmy mu to pytanie dwa razy - raz o samą wieżę, raz o to, co z niej odczytujemy.
console.log(typeof alertLevel); // function
console.log(typeof alertLevel()); // numberPierwsza linia mówi "function", druga "number", i w tej różnicy mieści się cała odpowiedź na pytanie o nawiasy. Bez nawiasów trzymasz w ręku wieżę, z nawiasami - to, co na niej widać. Oba wyniki wypisał uruchomiony kod. Zwróć uwagę, czego to sprawdzenie nie zmieniło: sygnał został dokładnie taki sam, jego wartość dalej wynosi zero, a samo zaglądanie do wieży niczego w niej nie porusza.
Krąży przy tym opowieść, że nawiasy trzeba pisać, bo inaczej TypeScript odmówi kompilacji. To nieprawda, a prawdziwy powód jest ciekawszy. Zapis console.log(alertLevel) bez nawiasów kompiluje się bez jednego ostrzeżenia - sprawdziłem to kompilatorem - tylko że przekazujesz wtedy dalej samą funkcję zamiast liczby. Błąd pojawia się dopiero wtedy, gdy spróbujesz użyć wieży tam, gdzie oczekiwana jest liczba: przypisanie const x: number = alertLevel kompilator odrzuca, tłumacząc, że sygnału liczby nie da się podstawić pod liczbę. Nawiasy nie są więc wymuszone przez kompilator - są konsekwencją tego, czym sygnał jest.
Zostaje mit ostatni i najbardziej niewinny: że sygnał to po prostu zmienna trzymana gdzieś w pamięci, a nawiasy są ozdobnikiem. Gdyby tak było, wartość dałoby się wyjąć którymś ze zwyczajowych sposobów - polem value, metodą get() albo metodą read(). Sprawdźmy więc, czy któraś z tych trzech nazw w ogóle istnieje.
console.log(typeof alertLevel.value); // undefined
console.log(typeof alertLevel.get); // undefined
console.log(typeof alertLevel.read); // undefinedTrzy razy "undefined", i to wynik z uruchomionego kodu: żadnej z tych nazw w sygnale nie ma. Kompilator jest jeszcze bardziej dosłowny, bo przy każdej z nich odpowiada, że taka właściwość nie istnieje na sygnale liczby. Nazwa value nie wzięła się z powietrza - tak odczytuje się wartość w reaktywności Vue, a get() i read() to nawyki przyniesione z innych bibliotek. W Angularze odczyt jest dokładnie jeden i zapisuje się dwoma pustymi nawiasami po nazwie sygnału. Nie zmieniło się przy tym nic w samej wieży: te trzy zapytania niczego nie ustawiły i niczego nie skasowały.
Rozkaz pierwszy: ustaw wartość
Wieża, która przez całą służbę pokazuje zero, jest tylko ozdobą muru. Zwiadowca wraca znad przełęczy i melduje, że na trakcie widziano obcy oddział, więc poziom alarmu trzeba podnieść. Odruch podpowiada zapis znany z pól klasy, czyli przypisanie znakiem równości - i przy sygnale ten odruch jest błędny na dwa sposoby naraz. Zapis alertLevel = 2 nie zmienia tego, co wieża pokazuje: podmienia całą wieżę na liczbę dwa, a więc niszczy sygnał, zamiast go zaktualizować. W praktyce nawet do tego nie dojdzie, bo kompilator odrzuca taką linię, tłumacząc, że liczby nie da się podstawić pod sygnał liczby - sprawdziłem to na polu klasy i błąd pojawia się natychmiast.
Do zmiany wartości służy metoda set(). Wywołujesz ją na sygnale i podajesz jej jeden argument: gotową nową wartość. Poprzednia zawartość wieży nie ma tu żadnego znaczenia, nikt jej nawet nie czyta. To rozkaz bezwzględny w rodzaju "ma być dwójka, i koniec dyskusji".
alertLevel.set(2);
console.log(alertLevel()); // 2Wieża pokazuje dwójkę. Najważniejsze jest jednak to, czego ten rozkaz nie ruszył: alertLevel to wciąż ten sam sygnał, ta sama funkcja, ten sam byt w pamięci - sprawdziłem to, zapamiętując odwołanie do niego przed zapisem i porównując je po. Wymieniła się wyłącznie wartość w środku, więc każde miejsce w aplikacji, które trzyma tę nazwę, zobaczy nową liczbę natychmiast i bez proszenia się o nią. Nie musiałeś też nikogo o niczym zawiadamiać: nie ma tu wywołania w rodzaju "odśwież widok" ani zdarzenia do wysłania. Angular sam odnotował zapis i sam obudzi tych, którzy z tej wieży czytali. Sama metoda niczego przy tym nie zwraca - wynikiem set() jest undefined, więc nie próbuj go do niczego przypisywać.
Zapis czytaj od lewej do prawej, bo kolejność kawałków jest zawsze ta sama: nazwa sygnału, kropka, słowo set, a na końcu nawias z nową wartością w środku. Przy wieży nazwanej count i wartości dziesięć wyszłoby z tego count.set(10) - najpierw count, potem ., potem set, potem (10). Moja rada: wszędzie tam, gdzie nowa wartość jest po prostu znana - zero po awansie, true po zalogowaniu, wybrany wiersz z listy - sięgaj po set() i nie kombinuj. Ten rozkaz czyta się w pół sekundy i nie da się go źle zrozumieć.
Bywają jednak rozkazy, których nie da się wydać bez spojrzenia na wieżę. "Podnieś alarm o jeden stopień" nie oznacza żadnej konkretnej liczby, dopóki nie wiesz, ile wieża pokazuje w tej chwili. Do takich sytuacji sygnał ma drugą metodę, update(), która zamiast gotowej wartości przyjmuje funkcję liczącą nową wartość ze starej. Zobaczysz ją za moment w kodzie komponentu, a zajmiemy się nią porządnie na następnym postoju - razem z pytaniem, kiedy który z tych dwóch rozkazów jest właściwy.
Wieża w komponencie
Wszystkie dotychczasowe linie były zwykłymi stałymi w pliku, żeby mechanika była widoczna bez rusztowania wokół. W prawdziwej aplikacji sygnały są polami klasy komponentu. Zbudujmy panel statystyk samuraja: pokaże imię wojownika, jego poziom i zdobyte doświadczenie, a dwa przyciski pozwolą te liczby zmieniać. Zaczynamy od samych pól, bez dekoratora i bez szablonu.
export class SamuraiStatsComponent {
name = signal('Musashi');
level = signal(1);
experience = signal(0);
}Trzy wieże, a w nich trzy różne zawartości: napis z imieniem, liczba poziomu i liczba doświadczenia. W sposobie stawiania niczego nowego nie ma - to dokładnie ta sama funkcja signal() co przy poziomie alarmu, tylko wywołana trzy razy i przypisana do pól zamiast do stałych. Sygnał nie wymaga, żeby w środku była liczba: napis, wartość logiczna, obiekt czy tablica pasują tak samo dobrze. Zwróć uwagę, czego w tej klasie nie ma: konstruktora, metody ngOnInit ani listy pól do obserwowania. Rytuały cyklu życia, które poznałeś w drugim module, nie są tu do niczego potrzebne, bo sygnał powstaje razem z obiektem klasy i od pierwszej chwili ma wartość.
Panel ma pokazywać jeszcze jedną liczbę: ile doświadczenia brakuje do następnego poziomu. Tej wartości nikt nie wpisuje ręcznie, bo wynika ona z dwóch pozostałych - z poziomu i ze zdobytego doświadczenia. W Angularze buduje się ją trzecią funkcją z pakietu @angular/core, nazwaną computed(), a nasze pole nazwiemy xpToNextLevel. Dziś zobaczysz je tylko przelotnie i tylko od strony odczytu: czyta się je nawiasami, dokładnie jak każdy inny sygnał. Skąd bierze się jego wartość i dlaczego nikt jej nigdy nie ustawia - o tym jest cała następna lekcja.
template: `
<h2>{{ name() }}</h2>
<p>Poziom: {{ level() }}</p>
<p>Doswiadczenie: {{ experience() }} XP</p>
<p>XP do nastepnego poziomu: {{ xpToNextLevel() }}</p>
`Szablon czyta wszystkie cztery pola tak samo: nazwa, puste nawiasy, całość w podwójnych klamrach interpolacji, którą znasz z drugiego modułu. Dla szablonu xpToNextLevel() niczym się nie różni od level() - obie linie dają po prostu liczbę. Nie zmieniła się przy tym ani jedna reguła interpolacji: klamry, wyrażenie, koniec. Nie ma tu też ani jednej linii, która pilnowałaby odświeżania - w szablonie nie zapisujesz, że coś ma się przerysować, tylko z czego ma się przerysować. Jedyna nowość wobec zwykłego pola to te dwa nawiasy; gdybyś je pominął, w klamrach znalazłaby się funkcja zamiast liczby, a użytkownik zobaczyłby na ekranie coś, czego nikt nie chciał mu pokazać.
Zostają dwa przyciski. Pierwszy dodaje sto punktów doświadczenia, drugi podnosi poziom i zeruje licznik doświadczenia. Pierwsza metoda liczy nową wartość ze starej, więc trafia na nią zapowiedziany przed chwilą update(). Druga kończy się rozkazem, który znasz już dokładnie: set(0), czyli "ma być zero, niezależnie od tego, ile było przed awansem".
gainExperience(amount: number): void {
this.experience.update(xp => xp + amount);
}
levelUp(): void {
this.level.update(lvl => lvl + 1);
this.experience.set(0);
}Wewnątrz klasy sygnały są polami, więc sięgasz po nie przez this - to najczęstsza pomyłka przy przepisywaniu przykładów z dokumentacji, gdzie sygnały bywają zwykłymi stałymi na poziomie pliku. Po kliknięciu drugiego przycisku poziom rośnie o jeden, a doświadczenie wraca do zera. Nie zmienia się przy tym nic poza tymi dwiema liczbami: żadna z metod nie dotyka pola name, nikt nie przebudowuje komponentu i nikt nie każe Angularowi odświeżać widoku. Zauważ też, że levelUp wydaje dwa osobne rozkazy do dwóch osobnych wież - jedna wieża to jedna wartość i nie ma sposobu, żeby jednym wywołaniem zmienić obie naraz.
Zbierzmy całą wieżę do kupy. Dekorator @Component znasz z pierwszego modułu: pole selector to nazwa znacznika, standalone: true oznacza komponent samodzielny, template niesie szablon. Znasz też wiązanie zdarzeń (click) z drugiego modułu. Nowe są wyłącznie cztery pola i dwie metody, które przed chwilą złożyłeś osobno.
import { Component, signal, computed } from '@angular/core';
@Component({
selector: 'app-samurai-stats',
standalone: true,
template: `
<h2>{{ name() }}</h2>
<p>Poziom: {{ level() }}</p>
<p>Doswiadczenie: {{ experience() }} XP</p>
<p>XP do nastepnego poziomu: {{ xpToNextLevel() }}</p>
<button (click)="gainExperience(100)">+100 XP</button>
<button (click)="levelUp()">Level Up!</button>
`
})
export class SamuraiStatsComponent {
name = signal('Musashi');
level = signal(1);
experience = signal(0);
xpToNextLevel = computed(() => this.level() * 1000 - this.experience());
gainExperience(amount: number): void {
this.experience.update(xp => xp + amount);
}
levelUp(): void {
this.level.update(lvl => lvl + 1);
this.experience.set(0);
}
}Cały panel to dwadzieścia kilka linii i ani jednej z nich nie poświęciłeś na odświeżanie ekranu. Lista importów z @angular/core urosła dokładnie o dwie nazwy wobec komponentów z pierwszych modułów - o signal i o computed - i to jest cały koszt wejścia w reaktywność. Kliknięcie w przycisk "+100 XP" zmienia jedną wieżę, experience, i to wystarczy: Angular wie, że patrzą na nią dwie linie szablonu, ta z liczbą XP i ta z brakującym doświadczeniem, więc zajmie się dokładnie nimi. Nie zmienia się przy tym nic w nagłówku z imieniem ani w linii z poziomem, bo żadna z nich tej wieży nie czytała. Zauważ na koniec, że w klasie nie ma ani jednej linii pilnującej spójności - nikt nie odejmuje doświadczenia od progu awansu ręcznie, a mimo to ostatnia liczba w szablonie zawsze się zgadza.
Co już umiesz, a co czeka za bramą
Masz komplet potrzebny do pierwszej służby na wieży. Funkcja signal() stawia wieżę i nadaje jej wartość początkową, puste nawiasy pokazują, co się na niej w tej chwili pali, a metoda set() wydaje rozkaz "ma być tyle". To wystarcza, żeby przenieść na sygnały każdy licznik, każdą flagę i każde pole formularza w Twojej aplikacji, i żeby żaden z nich nie potrzebował już gońca obchodzącego wszystkie zamki. Trzech rzeczy jeszcze nie umiesz i każda ma swój postój: liczenia nowej wartości ze starej wraz z wartościami, które przeliczają się same, reagowania na zmianę czymś spoza ekranu, na przykład wpisem do dziennika, oraz przyjmowania sygnałów od komponentu nadrzędnego.
Pamiętaj: sygnał to wieża, a nie liczba - nawiasy pokazują, co się na niej właśnie pali, a set() jest rozkazem, po którym dym idzie w górę bez pytania gońca o zgodę.
Hasła z dokumentacji w tej lekcji
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 tworzymy podstawowy signal w Angular?
2. Dlaczego odczyt wartości signal wymaga nawiasów, np. count()?
To 2 z 3 pytań do tej lekcji. Pozostałe rozwiążesz w grze.
Zadania praktyczne w grze
- Klikanie w kolejności
Ułóż wywołanie tworzące signal z wartością początkową 0.
- Edytor kodu
Uzupełnij komponent licznika: przycisk +1 ma liczyć nową wartość sygnału z poprzedniej, a Reset ustawiać zero.
- Układanie w poziomie
Ułóż w jednej linii instrukcję ustawiającą wartość signala count na 10.