Kurs Angular · Moduł 6: Signals

Metody sygnału - rozkazy dla wieży

15 min czytania ·
W tej lekcji7

Na poprzednim postoju zapaliłeś pierwszy sygnał dymny. Wywołałeś signal(), podałeś wartość początkową i odczytałeś ją nawiasami. Wieża stoi, dym idzie, sąsiedni zamek patrzy. Problem w tym, że wieża, która potrafi wyłącznie się zapalić, przestaje być komukolwiek potrzebna po pierwszej minucie obserwacji. Prawdziwa wartość wieży zaczyna się tam, gdzie strażnik musi coś w dymie zmienić.

Spójrz jeszcze raz na komponent statystyk samuraja z poprzedniej lekcji. Przemknęły w nim dwa wywołania, których nikt Ci nie wyjaśnił: this.experience.update(xp => xp + amount) przy zdobywaniu punktów oraz this.experience.set(0) przy awansie. Dwie różne nazwy, dwa różne rozkazy - i wcale nie są wymienne. Było tam też pole xpToNextLevel, którego nikt nigdy nie ustawiał, a które mimo to zawsze pokazywało właściwą liczbę. Trzy zagadki, jedna lekcja.

Mistrz Ango-san tłumaczy to tak. Na murach Akademii stoi rząd wież, a do strażnika przy każdej z nich dowódca może wysłać tylko trzy rodzaje rozkazów. Pierwszy brzmi "zapal dokładnie trzy słupy dymu" i strażnik nie musi nawet wiedzieć, ile paliło się przed chwilą. Drugi brzmi "dołóż jeden słup do tego, co już widać" - tu strażnik musi najpierw spojrzeć na własną wieżę. Trzeci rozkaz nie trafia do strażnika, tylko do skryby siedzącego w wieży dowodzenia: "sumuj dymy z wieży pierwszej i drugiej, i wywieszaj wynik na tablicy". Skryba niczego nie zapala, a jego tablica nigdy nie bywa nieaktualna. W kodzie te trzy rozkazy nazywają się set(), update() i computed(), i po tej lekcji będziesz umiał wydać każdy z nich.

Rozkaz pierwszy: ustaw wartość

Zanim wydasz jakikolwiek rozkaz, potrzebujesz wieży. Funkcję signal() importujesz z pakietu @angular/core, podajesz jej wartość początkową i dostajesz z powrotem sygnał zapisywalny - w dokumentacji Angulara jego typ nazywa się WritableSignal, czyli dosłownie "sygnał, do którego wolno pisać". Odczyt wygląda dokładnie tak, jak poznałeś wcześniej: nazwa i puste nawiasy, bo sygnał jest funkcją.

import { signal } from '@angular/core';

const count = signal(0);

console.log(count());  // 0

Powstała wieża z zerem na szczycie i to wszystko, co się na razie stało. Warto od razu wiedzieć, czego odczyt nie robi: nie zmienia wartości, nie tworzy kopii i niczego nie zużywa. Możesz wywołać count() sto razy pod rząd i za każdym razem dostaniesz tę samą liczbę. Zwróć też uwagę na typ, który TypeScript wywnioskował sam z wartości początkowej. signal(0) to sygnał liczby, więc próba wpisania do niego napisu skończy się błędem kompilacji, zanim aplikacja w ogóle wystartuje. To ta sama dyscyplina typów, którą poznałeś w pierwszym module przy kodeksie Bushido kodu, tylko schowana za jednym wywołaniem funkcji.

Teraz pierwszy rozkaz. Metoda set() przyjmuje jeden argument - gotową nową wartość - i wstawia ją w miejsce starej. Poprzednia zawartość sygnału nie ma tu żadnego znaczenia, nikt jej nawet nie czyta. To rozkaz bezwzględny: "ma być dziesięć, i koniec dyskusji".

count.set(10);

console.log(count());  // 10

Wieża pokazuje dziesięć. Najważniejsze jest jednak to, czego ten rozkaz nie ruszył: count to wciąż ten sam sygnał, ta sama funkcja, ten sam byt w pamięci. Wymieniła się wyłącznie wartość w środku, więc każde miejsce w aplikacji, które trzyma odwołanie do count, zobaczy nową liczbę natychmiast i bez żadnego zgłoszenia się po nią. Nie musiałeś też nikomu mówić, że coś się zmieniło - Angular sam odnotował zapis i sam obudzi tych, którzy z tej wieży korzystają.

Rozkaz drugi: przelicz od poprzedniej wartości

Bywają jednak rozkazy, których nie da się wydać bez spojrzenia na wieżę. "Dołóż jeden słup dymu" nie oznacza żadnej konkretnej liczby, dopóki nie wiesz, ile już się pali. Owszem, da się to obejść zapisem count.set(count() + 1), czyli "odczytaj, dodaj, ustaw" - ale to trzy czynności sklejone w jedną linię, a intencja ginie w nawiasach.

Do takich sytuacji służy update(). Ta metoda przyjmuje nie wartość, lecz funkcję. Angular sam wywoła ją, podając jej w argumencie aktualną zawartość sygnału, a to, co funkcja zwróci, zapisze jako nową wartość. Nazwa parametru należy do Ciebie: może to być v, current, xp czy lvl - kompilator nie ma tu żadnych wymagań.

count.update(v => v + 1);

console.log(count());  // 11

Z dziesięciu zrobiło się jedenaście, choć nigdzie nie pada liczba jedenaście. Przeczytaj to wywołanie od lewej do prawej, bo kolejność kawałków jest zawsze ta sama: najpierw count.update(, potem nazwa parametru v, potem strzałka =>, potem wyrażenie v + 1 budujące nową wartość, a na końcu zamykający ). Nie zmieniło się natomiast nic poza samą liczbą: sygnał jest ten sam co przed chwilą, a funkcja, którą podałeś, została wywołana dokładnie raz i już nigdy nie będzie potrzebna.

Skoro znasz oba rozkazy, ustalmy raz na zawsze, na czym polega między nimi różnica, bo krążą o tym trzy nieprawdziwe opowieści. Różnica jest jedna i dotyczy pochodzenia nowej wartości: set() dostaje ją gotową od Ciebie, update() wylicza ją z poprzedniej za pomocą funkcji, którą podałeś. To nie jest kwestia szybkości - obie metody kończą się dokładnie tym samym, czyli zapisaniem jednej wartości i powiadomieniem zależnych, więc opowieść o "szybszym set() i wolniejszym update()" nie ma się o co oprzeć. To nie jest też podział na typy danych: obie metody działają z liczbami, napisami, obiektami i tablicami bez różnicy, więc zdanie "set() dla obiektów, update() dla prymitywów" jest wymyślone od początku do końca. I na pewno nie są to aliasy tej samej metody - gdyby były, przyjmowałyby ten sam argument, a przyjmują zupełnie różne: jedna wartość, druga funkcję. Moja rada: wszędzie tam, gdzie nowa wartość wynika ze starej, wybieraj update(). Zapis count.update(v => v + 1) mówi wprost "zwiększ o jeden", podczas gdy count.set(count() + 1) każe czytelnikowi rozszyfrować to z trzech osobnych kroków.

Wieża, która jeszcze nic nie widziała

Nie każdy sygnał da się od razu wypełnić sensowną wartością. Zwiadowca dopiero wyruszył w góry i przez pierwsze godziny po prostu nie ma meldunku - a komponent musi już istnieć i coś wyświetlać. Potrzebny jest sygnał, który potrafi być pusty.

Umówmy się najpierw, czym w naszym kodzie jest samuraj. To obiekt z trzema polami: name typu string, level typu number i skills, czyli tablica napisów z nazwami opanowanych sztuk walki. Taki kształt opisujemy interfejsem - konstrukcją TypeScriptu, którą poznałeś w pierwszym module. Nazwiemy go Samurai i będzie nam służył do końca lekcji. Wartością początkową sygnału będzie null, czyli "jeszcze nic nie wiadomo", a dopuszczalny typ zapiszemy jako Samurai | null - pionowa kreska czyta się jako "albo".

interface Samurai {
  name: string;
  level: number;
  skills: string[];
}

const scout = signal<Samurai | null>(null);

Nawiasy ostrokątne po nazwie signal to parametr typu i tym razem są konieczne. Gdybyś ich nie podał, TypeScript wywnioskowałby z wartości początkowej, że to sygnał samego null, i odrzuciłby każdą późniejszą próbę wpisania tam samuraja. Poza tym jednym szczegółem nic się nie zmienia: scout.set(...) i scout.update(...) działają dokładnie tak samo jak przy liczniku, a odczyt scout() zwraca wartość typu Samurai | null, więc przed sięgnięciem po pole name trzeba najpierw sprawdzić, czy meldunek już przyszedł.

Rozkaz zakazany: wieża tylko do oglądania

Wyobraź sobie teraz, że wieżę obsługuje jeden strażnik, ale patrzy na nią pół zamku. Heroldzi mają dym czytać i roznosić wieści, nie mają prawa dorzucać własnych słupów. W kodzie ten układ pojawia się stale: serwis albo komponent trzyma sygnał u siebie, zmienia go we własnych metodach i chce udostępnić go innym w takiej postaci, żeby nikt z zewnątrz nie mógł go po cichu przestawić.

Służy do tego metoda asReadonly(). Wywołujesz ją bez żadnych argumentów, a w zamian dostajesz sygnał typu Signal, a nie WritableSignal - czyli taki, na którym nie ma już ani set(), ani update().

const readonlyCount = count.asReadonly();

console.log(readonlyCount());  // 11

Odczyt daje tę samą jedenastkę co przed chwilą, bo to nie jest kopia ani zamrożona migawka. To okno wychodzące na tę samą wieżę. Najlepiej widać to wtedy, gdy zmienisz oryginał: wersja tylko do odczytu natychmiast pokazuje nową wartość, mimo że sama nie ma czym jej zmienić.

count.set(99);

console.log(readonlyCount());  // 99

// readonlyCount.set(0);   <- blad kompilacji: ta metoda nie istnieje

Sprawdziłem to na uruchomionym sygnale: po zapisie do count odczyt przez readonlyCount zwraca dziewięćdziesiąt dziewięć, a właściwości set i update na tym obiekcie po prostu nie ma - są niezdefiniowane. Nie zmieniło się przy tym nic w samej wartości ani w oryginalnym sygnale: count nadal wolno zapisywać, bo prawo do wydawania rozkazów odbiera się tylko tej nazwie, którą rozdajesz na zewnątrz.

Trzy inne nazwy, które brzmią równie sensownie, nie istnieją i warto wiedzieć dlaczego. Metody freeze() w interfejsie sygnału nie ma - istnieje wprawdzie Object.freeze z czystego JavaScriptu, ale to zupełnie inna sprawa: zamraża właściwości zwykłego obiektu i o reaktywności Angulara nie wie nic. Metody lock() również nie ma, choć nazwa aż się prosi. Najpodstępniejsza jest trzecia: const(). Słowo const istnieje w JavaScripcie naprawdę, tylko że jest słowem kluczowym deklaracji, a nie metodą. Zauważ, że Twój licznik od początku powstał jako const count = signal(0) - i mimo tego const wywołałeś na nim przed chwilą set(99) bez żadnych przeszkód. const chroni bowiem wyłącznie samo powiązanie nazwy z sygnałem, a nie wartość, którą sygnał trzyma w środku.

Obiekty i tablice: nowy zwój zamiast skreślenia

Do tej pory wieża trzymała liczbę. Znacznie częściej trzyma cały obiekt - kartę samuraja, ustawienia gracza, listę zamówień. Skorzystajmy z interfejsu Samurai, na który umówiliśmy się wcześniej, i zbudujmy sygnał z prawdziwym wojownikiem w środku.

const samurai = signal<Samurai>({
  name: 'Musashi',
  level: 1,
  skills: ['Kendo']
});

Mamy Musashiego na pierwszym poziomie, z jedną opanowaną sztuką walki. Nic nowego się tu nie wydarzyło - to wciąż to samo signal() co przy liczniku, tylko z obiektem zamiast liczby. Kłopoty zaczynają się dopiero przy pierwszej próbie awansu, bo odruch podpowiada rozwiązanie, które wygląda niewinnie i jest kompletnie błędne.

// TAK NIE ROBIMY - sygnal tego nie zauwazy
samurai().level = 2;

Sprawdziłem to na uruchomionym sygnale i wynik jest gorszy, niż mogłoby się wydawać. Pole naprawdę się zmienia - odczyt samurai().level zwraca dwójkę - ale nikt zależny o tym nie wie. Wartość pochodna wyliczana z tego sygnału nadal zwracała starą liczbę i nie przeliczyła się ani razu. Powód jest prosty: Angular sprawdza, czy sygnał ma nową wartość, porównując ją ze starą przez tożsamość, a Ty podmieniłeś pole wewnątrz obiektu, nie podając nowego obiektu. Dla sygnału to wciąż ten sam byt pod tym samym adresem, więc nie ma o czym meldować. To najgorszy możliwy rodzaj błędu, bo aplikacja nie wyrzuca żadnego wyjątku - po prostu ekran pokazuje nieaktualne dane.

Rozwiązanie w Akademii nazwalibyśmy przepisaniem zwoju. Nie skreślasz linijki w starym dokumencie, tylko spisujesz nowy, przenosząc do niego wszystko, co bez zmian, i wpisując po swojemu to jedno pole. W JavaScripcie służy do tego operator rozproszenia, zapisywany trzema kropkami: ...s wkłada do nowego obiektu wszystkie pola starego. Pole wymienione po nim nadpisuje to, co przyszło z kopii.

samurai.update(s => ({
  ...s,
  level: s.level + 1
}));

Samuraj jest na drugim poziomie, a sygnał tym razem wie o awansie, bo dostał zupełnie nowy obiekt. Zwróć uwagę na nawiasy okrągłe wokół klamry - bez nich JavaScript uznałby { za początek ciała funkcji, a nie za literał obiektu, i całość nie miałaby sensu. Najważniejsze jest jednak to, co pozostało nietknięte: pola name i skills nikt nie przepisywał ręcznie, przyjechały same przez trzy kropki, a poprzedni obiekt nadal istnieje i nadal ma poziom pierwszy - sprawdziłem to, trzymając do niego odwołanie przed aktualizacją. Nowa wartość nie niszczy starej, tylko staje się obok niej.

Ta sama zasada obowiązuje przy tablicach, tylko trzy kropki wędrują do środka nawiasów kwadratowych. Zapis [...s.skills, 'Iaido'] buduje nową tablicę: najpierw wszystkie dotychczasowe umiejętności, na końcu dopisana. Poniższy blok to cztery linie, które w edytorze zobaczysz jedna pod drugą i zawsze w tej kolejności - otwarcie aktualizacji, kopia pól, nowa zawartość tablicy, zamknięcie.

samurai.update(s => ({
  ...s,
  skills: [...s.skills, 'Iaido']
}));

Musashi ma teraz dwie sztuki walki: Kendo i Iaido. Nie zmieniły się przy tym ani name, ani level - obie wartości przeszły przez ...s bez tknięcia, a stara tablica z samym Kendo dalej leży nieruszona w poprzednim obiekcie. Odruchowo chciałoby się napisać tu s.skills.push('Iaido') i to jest dokładnie ta sama pułapka co przed chwilą: push dopisuje do istniejącej tablicy w miejscu, więc obiekt zostaje ten sam i sygnał znowu o niczym nie zamelduje.

Skryba na tablicy: computed()

Zostaje najciekawszy rozkaz, ten wydawany skrybie. Wyobraź sobie prosty rachunek: cena jednostkowa, liczba sztuk, stawka podatku i suma do zapłaty. Wartość netto, podatek i suma dają się policzyć z trzech pierwszych liczb, więc trzymanie ich w osobnych zapisywalnych sygnałach byłoby proszeniem się o kłopoty - po każdej zmianie ceny musiałbyś pamiętać o trzech wywołaniach set() we właściwej kolejności. Pewnego dnia zapomnisz o jednym i tablica na murze zacznie kłamać.

Funkcję computed() importujesz z tego samego pakietu @angular/core. Podajesz jej funkcję bez argumentów, a ona zwraca sygnał, którego wartości nigdy sam nie ustawiasz - Angular wylicza ją, wołając Twoją funkcję. Przy okazji zapamiętuje, które sygnały zostały w środku odczytane, i traktuje je jako zależności. Zacznijmy od trzech zwykłych wież, z których skryba będzie czytał.

import { computed } from '@angular/core';

const price = signal(100);
const quantity = signal(2);
const taxRate = signal(0.23);

To trzy zapisywalne sygnały i nic więcej - żaden z nich nikogo nie obserwuje, każdy trzyma tylko swoją liczbę. Cała rachunkowość dopiero powstanie, i to w trzech krokach, bo wartości pochodne wolno budować także z innych wartości pochodnych. Suma częściowa subtotal bierze się z ceny i ilości. Podatek tax liczy się z sumy częściowej i stawki. Całość total to suma częściowa powiększona o podatek. Zapisuj je w tej właśnie kolejności - najpierw ten, z którego korzystają pozostałe - bo tak czyta się je jak zdanie i nie zdarzy się odwołanie do stałej, która jeszcze nie powstała.

const subtotal = computed(() => price() * quantity());
const tax = computed(() => subtotal() * taxRate());
const total = computed(() => subtotal() + tax());

console.log(total());  // 246

Uruchomiłem ten kod i wypisał dwieście czterdzieści sześć. Rachunek jest przejrzysty: sto razy dwa daje dwieście, dwieście razy dwadzieścia trzy setne daje czterdzieści sześć, a dwieście plus czterdzieści sześć daje dwieście czterdzieści sześć. Wewnątrz każdej z tych funkcji sygnały odczytujesz tak samo jak zawsze, przez nawiasy - price(), quantity(), subtotal(). Zauważ przy tym, że nigdzie nie wywołałeś subtotal.set(...): taka metoda na wartości pochodnej nie istnieje i nie ma jej po co szukać.

A teraz najciekawsze. Zmień jedną jedyną rzecz, cenę, i odczytaj sumę ponownie.

price.set(200);

console.log(total());  // 492

Wypisało czterysta dziewięćdziesiąt dwa i to jest liczba prawdziwa, sprawdzona na działającym kodzie: dwieście razy dwa to czterysta, czterysta razy stawka to dziewięćdziesiąt dwa, razem czterysta dziewięćdziesiąt dwa. Zwróć uwagę, czego nie zrobiłeś, bo to najważniejsze zdanie tej lekcji. Nie przepisałeś ani jednej z trzech definicji. Nie wywołałeś żadnego "przelicz". Nie ustawiłeś subtotal, tax ani total w żadnym miejscu programu. Zmieniłeś wieżę, a skryba sam poprawił tablicę, bo sam pamiętał, z czego ją liczy. Tak właśnie działało xpToNextLevel w komponencie z poprzedniej lekcji.

Ustalmy więc dokładnie, czym wartość pochodna jest, bo pod tę definicję podszywają się trzy inne zdania. Owszem, computed() zwraca sygnał tylko do odczytu - ale samo "tylko do odczytu" niczego nie tłumaczy, bo asReadonly() też zwraca sygnał tylko do odczytu, a nic nie przelicza. Owszem, wartość pochodna jest wydajna: sprawdziłem to i funkcja nie wykonuje się ani razu, dopóki ktoś nie odczyta wyniku, a trzy odczyty pod rząd wywołują ją tylko raz, bo Angular zapamiętuje ostatni wynik; zapis tej samej wartości do źródła w ogóle nie budzi zależnych. To jednak skutek uboczny, a nie definicja - "sygnał zoptymalizowany dla wydajności" to opis czegokolwiek i niczego. Nie chodzi też o żadną matematykę: wartość pochodna równie dobrze zwraca napis, warunek albo przefiltrowaną listę.

const battleReady = computed(() => samurai().skills.length >= 2);
const label = computed(() => samurai().name + ' (poziom ' + samurai().level + ')');

Pierwsza z nich zwraca prawdę, bo Musashi opanował dwie sztuki walki, druga - napis "Musashi (poziom 2)". Obie sprawdziłem na uruchomionym kodzie. Ani jedna nie liczy niczego matematycznego, a obie są pełnoprawnymi wartościami pochodnymi, bo definicja jest tylko jedna: to sygnał, którego wartość Angular przelicza automatycznie, gdy zmieni się którakolwiek z jego zależności. Gdybyś potrzebował podwojonej ceny, wystarczyłoby computed(() => price() * 2) - ten sam wzór, pięć kawałków w stałej kolejności: nazwa computed, nawias otwierający, strzałka bezargumentowa, wyrażenie, nawias zamykający.

Skryba w komponencie

Wszystkie dotychczasowe przykłady były zwykłymi stałymi na poziomie pliku, żeby mechanika była widoczna bez żadnego rusztowania wokół. W prawdziwej aplikacji te same sygnały są polami klasy komponentu, więc odczytujesz je przez this - i, co ważne, także wewnątrz funkcji podanej do computed(), bo tam obowiązuje ten sam obiekt. To najczęstsza pomyłka przy przenoszeniu przykładów z dokumentacji do własnego komponentu.

Reszta poniższego kodu jest Ci znana z pierwszych modułów: dekorator @Component z polem selector, czyli nazwą znacznika, z polem standalone oznaczającym komponent samodzielny i z polem template, w którym wartości wstawia się w podwójne klamry. Nowe są wyłącznie trzy pola klasy.

import { Component, signal, computed } from '@angular/core';

@Component({
  selector: 'app-tower-ledger',
  standalone: true,
  template: `
    <p>Sztuk: {{ quantity() }}</p>
    <p>Cena: {{ price() }}</p>
    <p>Razem: {{ subtotal() }}</p>
  `
})
export class TowerLedgerComponent {
  price = signal(100);
  quantity = signal(2);

  subtotal = computed(() => this.price() * this.quantity());
}

Szablon czyta wszystkie trzy sygnały tak samo, przez nawiasy, i nie odróżnia wartości pochodnej od zwykłej - dla niego subtotal() to po prostu liczba. Klasa nie zyskała przy tym żadnej nowej metody: nie ma tu ngOnInit, nie ma ręcznego odświeżania, nie ma nic poza trzema polami. Kiedy w jakiejkolwiek metodzie wywołasz this.price.set(120), Angular przeliczy subtotal i odświeży wyłącznie ten fragment widoku, który z niego korzysta. Dekorator wygląda dokładnie tak, jak poznałeś go w pierwszym module przy niezależnych wojownikach - selektor, znacznik komponentu samodzielnego i szablon.

Pamiętaj: set() to rozkaz "zapal dokładnie tyle dymu", update() to "dołóż do tego, co już widać", asReadonly() to okno dla heroldów, a computed() to skryba, który sam czyta wieże i sam poprawia tablicę - a na następnym postoju dowiesz się, co zrobić, gdy na zmianę dymu ma zareagować ktoś spoza murów Akademii.

Widzisz błąd w tej lekcji?

Sprawdź się

Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.

  1. 1. Jaka jest różnica między set() a update() dla signal?

  2. 2. Która metoda zwraca wersję signala, której nie da się modyfikować przez set() ani update()?

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

Zadania praktyczne w grze

  • Układanie w poziomie

    Ułóż w jednej linii wywołanie zwiększające signal count o 1 na podstawie poprzedniej wartości.

  • Edytor kodu

    Uzupełnij komponent koszyka: suma ma być sygnałem tylko do odczytu, który sam przelicza się z ilości i ceny.

  • Układanie w pionie

    Uporządkuj kroki dodania nowej umiejętności do signala z obiektem samuraja (immutowalnie).

  • Układanie w pionie

    Uporządkuj definicje computed signals tak, aby każdy korzystał z poprzedniego (subtotal → tax → total).

  • Klikanie w kolejności

    Ułóż początek definicji computed signala dla pełnej ceny: full = ...

Przydatne artykuły